Your Name 0b3dfa3c52 feat: AI 审核自动驳回 + 功效词可配置 + UI 修复
- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段
- 功效词可配置:从硬编码改为品牌方在规则页面自行管理
- 驳回通知:AI 驳回时只通知达人,含具体原因
- 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口
- 规则页面:新增"功效词"分类
- 种子数据:新增 6 条默认功效词
- 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:24:32 +08:00

670 lines
33 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, 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<typeof useToast>; briefData: BriefUI }) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(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 (
<>
<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">
<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">
{briefData.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={() => setPreviewFile(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">
{corePoints.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">{corePoints.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>
)}
{recommendedPoints.length > 0 && (
<div className="p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{recommendedPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded">{sp.content}</span>
))}</div>
</div>
)}
{referencePoints.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">{referencePoints.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">{briefData.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></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({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<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"> WordPDFTXTExcel </p>
<input type="file" accept=".doc,.docx,.pdf,.txt,.xls,.xlsx" onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{isUploading ? (
<Loader2 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<FileText size={14} className="text-accent-indigo flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
{!isUploading && (
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
)}
{isUploading && (
<p className="mt-1 ml-[30px] text-xs text-text-tertiary"> {progress}%</p>
)}
{uploadError && (
<p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>
)}
</div>
</div>
)}
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交脚本'}
</Button>
</CardContent>
</Card>
)
}
function AIReviewingSection() {
const [progress, setProgress] = useState(0)
const [logs, setLogs] = useState<string[]>(['开始解析脚本文件...'])
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 (
<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 getDimensionLabel(key: string) {
const labels: Record<string, string> = { 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 (
<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">
{dimensions && (
<div className="grid grid-cols-2 gap-3">
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
const dim = dimensions[key]
if (!dim) return null
return (
<div key={key} className={`p-3 rounded-lg border ${dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20'}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
</div>
<span className={`text-lg font-bold ${dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral'}`}>{dim.score}</span>
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} )</span>}
</div>
)
})}
</div>
)}
{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" /> ({violations.length})</h4>
{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>
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
</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>
)}
{briefMatchDetail && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-indigo" />Brief </h4>
<div className="p-3 bg-bg-elevated rounded-lg space-y-3">
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
{briefMatchDetail.total_points > 0 && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary"></span>
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} </span>
</div>
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral'}`} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
</div>
</div>
)}
{briefMatchDetail.highlights.length > 0 && (
<div>
<p className="text-xs text-accent-green font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.highlights.map((h, i) => (
<div key={i} className="flex items-start gap-2">
<CheckCircle size={14} className="text-accent-green flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{h}</span>
</div>
))}
</div>
</div>
)}
{briefMatchDetail.issues.length > 0 && (
<div>
<p className="text-xs text-accent-coral font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle size={14} className="text-accent-coral flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{issue}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{sellingPointMatches && sellingPointMatches.length > 0 && (
<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">
{sellingPointMatches.map((sp, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
{sp.matched ? <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">
<div className="flex items-center gap-2">
<span className="text-sm text-text-primary">{sp.content}</span>
<span className={`px-1.5 py-0.5 text-xs rounded ${
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
'bg-bg-page text-text-tertiary'
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
</div>
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
}
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 (
<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 toast = useToast()
const { subscribe } = useSSE()
const taskId = params.id as string
const [task, setTask] = useState<ScriptTaskUI>(mockDefaultTask)
const [briefData, setBriefData] = useState<BriefUI>(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<string, string> = {
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 <div className="flex items-center justify-center h-64"><Loader2 className="w-8 h-8 text-accent-indigo animate-spin" /></div>
}
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>
<AgencyBriefSection toast={toast} briefData={briefData} />
{task.scriptStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
{task.scriptStatus === 'ai_rejected' && (
<>
<Card className="border-accent-coral/30 bg-accent-coral/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<XCircle size={20} className="text-accent-coral mt-0.5 flex-shrink-0" />
<div>
<p className="text-text-primary font-medium">AI </p>
{task.aiRejectReason && <p className="text-sm text-text-secondary mt-1">{task.aiRejectReason}</p>}
</div>
</div>
</CardContent>
</Card>
<AIResultSection task={task} />
<UploadSection taskId={taskId} onUploaded={loadTask} />
</>
)}
{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={loadTask} 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={loadTask} fullWidth><RefreshCw size={16} /></Button></div></>
)}
</div>
)
}