'use client' import { useState, useEffect, useCallback } from 'react' import { useRouter, useParams } 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 } from '@/components/ui/Tag' import { ReviewSteps, getReviewSteps } from '@/components/ui/ReviewSteps' import { ArrowLeft, Upload, FileText, CheckCircle, XCircle, AlertTriangle, Clock, Loader2, RefreshCw, Eye, Download, File, Target, Ban, ChevronDown, ChevronUp } from 'lucide-react' import { Modal } from '@/components/ui/Modal' import { api } from '@/lib/api' import { USE_MOCK } from '@/contexts/AuthContext' import { useSSE } from '@/contexts/SSEContext' import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task' import type { BriefResponse } from '@/types/brief' // ========== 工具函数 ========== function getSellingPointPriority(sp: { priority?: string; required?: boolean }): 'core' | 'recommended' | 'reference' { if (sp.priority) return sp.priority as 'core' | 'recommended' | 'reference' if (sp.required === true) return 'core' if (sp.required === false) return 'recommended' return 'recommended' } // ========== 类型 ========== type AgencyBriefFile = { id: string; name: string; size: string; uploadedAt: string; description?: string } type ScriptTaskUI = { projectName: string brandName: string scriptStatus: string scriptFile: string | null aiAutoRejected?: boolean aiRejectReason?: string aiResult: null | { score: number dimensions?: ReviewDimensions sellingPointMatches?: SellingPointMatchResult[] briefMatchDetail?: BriefMatchDetail violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }> } agencyReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string } brandReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string } } type BriefUI = { files: AgencyBriefFile[] sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[] blacklistWords: { id: string; word: string; reason: string }[] } // ========== 映射 ========== function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI { const stage = task.stage let status = 'pending_upload' const aiAutoRejected = task.script_ai_result?.ai_auto_rejected === true switch (stage) { case 'script_upload': status = aiAutoRejected ? 'ai_rejected' : 'pending_upload' break case 'script_ai_review': status = 'ai_reviewing'; break case 'script_agency_review': status = 'agent_reviewing'; break case 'script_brand_review': status = 'brand_reviewing'; break default: if (stage.startsWith('video_') || stage === 'completed') status = 'brand_passed' if (stage === 'rejected') { if (task.script_brand_status === 'rejected') status = 'brand_rejected' else if (task.script_agency_status === 'rejected') status = 'agent_rejected' else status = 'ai_result' } } // 有 AI 结果且还在脚本审核阶段 → ai_result if (task.script_ai_result && stage === 'script_agency_review') status = 'agent_reviewing' const aiResult = task.script_ai_result ? { score: task.script_ai_result.score, dimensions: task.script_ai_result.dimensions, sellingPointMatches: task.script_ai_result.selling_point_matches, briefMatchDetail: task.script_ai_result.brief_match_detail, violations: task.script_ai_result.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })), } : null const agencyReview = task.script_agency_status && task.script_agency_status !== 'pending' ? { result: (task.script_agency_status === 'passed' || task.script_agency_status === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected', comment: task.script_agency_comment || '', reviewer: task.agency?.name || '代理商', time: task.updated_at, } : null const brandReview = task.script_brand_status && task.script_brand_status !== 'pending' ? { result: (task.script_brand_status === 'passed' || task.script_brand_status === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected', comment: task.script_brand_comment || '', reviewer: '品牌方审核员', time: task.updated_at, } : null return { projectName: task.project?.name || task.name, brandName: task.project?.brand_name || '', scriptStatus: status, scriptFile: task.script_file_name || null, aiAutoRejected, aiRejectReason: task.script_ai_result?.ai_reject_reason, aiResult, agencyReview, brandReview, } } function mapBriefToUI(brief: BriefResponse): BriefUI { return { files: (brief.attachments || []).map((a, i) => ({ id: a.id || `att-${i}`, name: a.name, size: a.size || '', uploadedAt: brief.updated_at || '', })), sellingPoints: (brief.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, priority: getSellingPointPriority(sp) })), blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, word: bw.word, reason: bw.reason })), } } // Mock 数据 const mockBrief: BriefUI = { files: [ { id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02' }, { id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02' }, ], sellingPoints: [ { id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const }, { id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const }, { id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const }, ], blacklistWords: [ { id: 'bw1', word: '最好', reason: '绝对化用语' }, { id: 'bw2', word: '第一', reason: '绝对化用语' }, ], } const mockDefaultTask: ScriptTaskUI = { projectName: 'XX品牌618推广', brandName: 'XX护肤品牌', scriptStatus: 'pending_upload', scriptFile: null, aiResult: null, agencyReview: null, brandReview: null, } // ========== UI 组件 ========== function AgencyBriefSection({ toast, briefData }: { toast: ReturnType; briefData: BriefUI }) { const [isExpanded, setIsExpanded] = useState(true) const [previewFile, setPreviewFile] = useState(null) const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) } const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core') const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended') const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference') return ( <> Brief 文档与要求 {isExpanded && (

参考文档

{briefData.files.map((file) => (

{file.name}

{file.size}

))}

卖点要求

{corePoints.length > 0 && (

核心卖点(建议优先提及)

{corePoints.map((sp) => ( {sp.content} ))}
)} {recommendedPoints.length > 0 && (

推荐卖点(建议提及)

{recommendedPoints.map((sp) => ( {sp.content} ))}
)} {referencePoints.length > 0 && (

参考信息

{referencePoints.map((sp) => ( {sp.content} ))}
)}

违禁词

{briefData.blacklistWords.map((bw) => ( 「{bw.word}」 ))}
)}
setPreviewFile(null)} title={previewFile?.name || '文件预览'} size="lg">

文件预览区域

{previewFile && }
) } function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) { const [file, setFile] = useState(null) const [isUploading, setIsUploading] = useState(false) const [progress, setProgress] = useState(0) const [uploadError, setUploadError] = useState(null) const toast = useToast() const handleFileChange = (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0] if (selectedFile) { setFile(selectedFile) setUploadError(null) } } const handleSubmit = async () => { if (!file) return setIsUploading(true) setProgress(0) setUploadError(null) try { if (USE_MOCK) { for (let i = 0; i <= 100; i += 20) { await new Promise(r => setTimeout(r, 400)) setProgress(i) } toast.success('脚本已提交,等待 AI 审核') onUploaded() } else { const result = await api.proxyUpload(file, 'script', (pct) => { setProgress(Math.min(90, Math.round(pct * 0.9))) }) setProgress(95) await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name }) setProgress(100) toast.success('脚本已提交,等待 AI 审核') onUploaded() } } catch (err) { const msg = err instanceof Error ? err.message : '上传失败' setUploadError(msg) toast.error(msg) } finally { setIsUploading(false) } } const formatSize = (bytes: number) => { if (bytes < 1024) return bytes + 'B' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB' return (bytes / (1024 * 1024)).toFixed(1) + 'MB' } return ( 上传脚本 {!file ? ( ) : (
已选文件
{isUploading ? ( ) : uploadError ? ( ) : ( )} {file.name} {formatSize(file.size)} {!isUploading && ( )}
{isUploading && (
)} {isUploading && (

上传中 {progress}%

)} {uploadError && (

{uploadError}

)}
)} ) } function AIReviewingSection() { const [progress, setProgress] = useState(0) const [logs, setLogs] = useState(['开始解析脚本文件...']) useEffect(() => { const timer = setInterval(() => { setProgress(prev => prev >= 100 ? (clearInterval(timer), 100) : prev + 10) }, 500) const t1 = setTimeout(() => setLogs(prev => [...prev, '正在提取文本内容...']), 1000) const t2 = setTimeout(() => setLogs(prev => [...prev, '正在进行违禁词检测...']), 2000) const t3 = setTimeout(() => setLogs(prev => [...prev, '正在分析卖点覆盖...']), 3000) return () => { clearInterval(timer); clearTimeout(t1); clearTimeout(t2); clearTimeout(t3) } }, []) return (

AI 正在审核您的脚本

请稍候,预计需要 1-2 分钟

{progress}%

处理日志

{logs.map((log, idx) =>

{log}

)}
) } function getDimensionLabel(key: string) { const labels: Record = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' } return labels[key] || key } function AIResultSection({ task }: { task: ScriptTaskUI }) { if (!task.aiResult) return null const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult return ( AI 审核结果 = 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral'}`}>{task.aiResult.score}分 {dimensions && (
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => { const dim = dimensions[key] if (!dim) return null return (
{getDimensionLabel(key)} {dim.passed ? : }
= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral'}`}>{dim.score} {dim.issue_count > 0 && ({dim.issue_count} 项问题)}
) })}
)} {violations.length > 0 && (

违规检测 ({violations.length})

{violations.map((v, idx) => (
{v.type} {v.dimension && {getDimensionLabel(v.dimension)}}

「{v.content}」

{v.suggestion}

))}
)} {briefMatchDetail && (

Brief 匹配度分析

{briefMatchDetail.explanation}

{briefMatchDetail.total_points > 0 && (
卖点覆盖率 {briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} 条
= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral'}`} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
)} {briefMatchDetail.highlights.length > 0 && (

亮点

{briefMatchDetail.highlights.map((h, i) => (
{h}
))}
)} {briefMatchDetail.issues.length > 0 && (

可改进

{briefMatchDetail.issues.map((issue, i) => (
{issue}
))}
)}
)} {sellingPointMatches && sellingPointMatches.length > 0 && (

卖点匹配

{sellingPointMatches.map((sp, idx) => (
{sp.matched ? : }
{sp.content} {sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}
{sp.evidence &&

{sp.evidence}

}
))}
)} ) } function ReviewFeedbackSection({ review, type }: { review: { result: string; comment: string; reviewer: string; time: string }; type: 'agency' | 'brand' }) { const isApproved = review.result === 'approved' const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见' return ( {isApproved ? : }{title}
{review.reviewer} {isApproved ? 通过 : 驳回}

{review.comment}

{review.time}

) } function WaitingSection({ message }: { message: string }) { return (

{message}

请耐心等待,审核结果将通过消息通知您

) } function SuccessSection({ onContinue }: { onContinue: () => void }) { return (

脚本审核通过!

您可以开始拍摄视频了

) } // ========== 主页面 ========== export default function CreatorScriptPage() { const router = useRouter() const params = useParams() const toast = useToast() const { subscribe } = useSSE() const taskId = params.id as string const [task, setTask] = useState(mockDefaultTask) const [briefData, setBriefData] = useState(mockBrief) const [isLoading, setIsLoading] = useState(true) const loadTask = useCallback(async () => { if (USE_MOCK) { setIsLoading(false) return } try { const apiTask = await api.getTask(taskId) setTask(mapApiToScriptUI(apiTask)) if (apiTask.project?.id) { try { const brief = await api.getBrief(apiTask.project.id) setBriefData(mapBriefToUI(brief)) } catch { /* Brief may not exist */ } } } catch (err) { toast.error('加载任务失败') } finally { setIsLoading(false) } }, [taskId, toast]) useEffect(() => { loadTask() }, [loadTask]) useEffect(() => { const unsub1 = subscribe('task_updated', (data) => { if ((data as { task_id?: string }).task_id === taskId) loadTask() }) const unsub2 = subscribe('review_completed', (data) => { if ((data as { task_id?: string }).task_id === taskId) loadTask() }) return () => { unsub1(); unsub2() } }, [subscribe, taskId, loadTask]) // AI 审核中时轮询(SSE 的后备方案) useEffect(() => { if (task.scriptStatus !== 'ai_reviewing' || USE_MOCK) return const interval = setInterval(() => { loadTask() }, 5000) return () => clearInterval(interval) }, [task.scriptStatus, loadTask]) const handleContinueToVideo = () => { router.push(`/creator/task/${params.id}/video`) } const getStatusDisplay = () => { const map: Record = { pending_upload: '待上传脚本', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成', agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回', brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回', } return map[task.scriptStatus] || '未知状态' } if (isLoading) { return
} return (

{task.projectName}

脚本阶段 · {getStatusDisplay()}

{task.scriptStatus === 'pending_upload' && } {task.scriptStatus === 'ai_rejected' && ( <>

AI 审核未通过,请修改后重新上传

{task.aiRejectReason &&

{task.aiRejectReason}

}
)} {task.scriptStatus === 'ai_reviewing' && } {task.scriptStatus === 'ai_result' && <>} {task.scriptStatus === 'agent_reviewing' && <>} {task.scriptStatus === 'agent_rejected' && task.agencyReview && ( <>
)} {task.scriptStatus === 'brand_reviewing' && task.agencyReview && ( <> )} {task.scriptStatus === 'brand_passed' && task.agencyReview && task.brandReview && ( <> )} {task.scriptStatus === 'brand_rejected' && task.agencyReview && task.brandReview && ( <>
)}
) }