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

441 lines
21 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, Video, CheckCircle, XCircle, AlertTriangle,
Clock, Loader2, RefreshCw, Play, Radio, Shield
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse } from '@/types/task'
// ========== 类型 ==========
type VideoTaskUI = {
projectName: string
brandName: string
videoStatus: string
videoFile: string | null
aiAutoRejected?: boolean
aiRejectReason?: string
aiResult: null | {
score: number
hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }>
sentimentWarnings: Array<{ type: string; content: string; timestamp: number }>
sellingPointsCovered: Array<{ point: string; covered: boolean; timestamp?: number }>
}
agencyReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
brandReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
}
// ========== 映射 ==========
function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
const stage = task.stage
let status = 'pending_upload'
const aiAutoRejected = task.video_ai_result?.ai_auto_rejected === true
switch (stage) {
case 'video_upload': status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'; break
case 'video_ai_review': status = 'ai_reviewing'; break
case 'video_agency_review': status = 'agent_reviewing'; break
case 'video_brand_review': status = 'brand_reviewing'; break
case 'completed': status = 'brand_passed'; break
default:
if (stage.startsWith('script_')) status = 'pending_upload' // 还没到视频阶段
if (stage === 'rejected') {
if (task.video_brand_status === 'rejected') status = 'brand_rejected'
else if (task.video_agency_status === 'rejected') status = 'agent_rejected'
else status = 'ai_result'
}
}
const aiResult = task.video_ai_result ? {
score: task.video_ai_result.score,
hardViolations: task.video_ai_result.violations
.filter(v => v.severity === 'error' || v.severity === 'high')
.map(v => ({ type: v.type, content: v.content, timestamp: v.timestamp || 0, suggestion: v.suggestion })),
sentimentWarnings: (task.video_ai_result.soft_warnings || [])
.map(w => ({ type: w.type, content: w.content, timestamp: 0 })),
sellingPointsCovered: [], // 后端暂无此字段
} : null
const agencyReview = task.video_agency_status && task.video_agency_status !== 'pending' ? {
result: (task.video_agency_status === 'passed' || task.video_agency_status === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: task.video_agency_comment || '',
reviewer: task.agency?.name || '代理商',
time: task.updated_at,
} : null
const brandReview = task.video_brand_status && task.video_brand_status !== 'pending' ? {
result: (task.video_brand_status === 'passed' || task.video_brand_status === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: task.video_brand_comment || '',
reviewer: '品牌方审核员',
time: task.updated_at,
} : null
return {
projectName: task.project?.name || task.name,
brandName: task.project?.brand_name || '',
videoStatus: status,
videoFile: task.video_file_name || null,
aiAutoRejected,
aiRejectReason: task.video_ai_result?.ai_reject_reason,
aiResult,
agencyReview,
brandReview,
}
}
const mockDefaultTask: VideoTaskUI = {
projectName: 'XX品牌618推广', brandName: 'XX护肤品牌',
videoStatus: 'pending_upload', videoFile: null, aiResult: null, agencyReview: null, brandReview: null,
}
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// ========== UI 组件 ==========
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 handleUpload = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 10) {
await new Promise(r => setTimeout(r, 300))
setProgress(i)
}
toast.success('视频已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, 'video', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskVideo(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-purple-400" /></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"> MP4MOVAVI 500MB</p>
<input type="file" accept="video/*" 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-purple-400 flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<Video size={14} className="text-purple-400 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-purple-400 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={handleUpload} disabled={!file || isUploading} fullWidth>
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交视频'}
</Button>
</CardContent>
</Card>
)
}
function AIReviewingSection() {
const [progress, setProgress] = useState(0)
const [currentStep, setCurrentStep] = useState('正在解析视频...')
useEffect(() => {
const steps = ['正在解析视频...', '正在提取音频转文字...', '正在分析画面内容...', '正在检测违禁内容...', '正在分析卖点覆盖...', '正在生成审核报告...']
let stepIndex = 0
const timer = setInterval(() => { setProgress(prev => prev >= 100 ? (clearInterval(timer), 100) : prev + 5) }, 300)
const stepTimer = setInterval(() => { stepIndex = (stepIndex + 1) % steps.length; setCurrentStep(steps[stepIndex]) }, 1500)
return () => { clearInterval(timer); clearInterval(stepTimer) }
}, [])
return (
<Card><CardContent className="py-8 text-center">
<Loader2 size={48} className="mx-auto text-purple-400 mb-4 animate-spin" />
<h3 className="text-lg font-medium text-text-primary mb-2">AI </h3>
<p className="text-text-secondary mb-4"> 3-5 </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-purple-400 transition-all" style={{ width: `${progress}%` }} /></div>
<p className="text-sm text-text-tertiary">{progress}%</p>
</div>
<div className="mt-4 p-3 bg-bg-elevated rounded-lg max-w-md mx-auto"><p className="text-sm text-text-secondary">{currentStep}</p></div>
</CardContent></Card>
)
}
function AIResultSection({ task }: { task: VideoTaskUI }) {
if (!task.aiResult) return null
return (
<div className="space-y-4">
<Card><CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-text-secondary">AI </span>
<span className={`text-3xl font-bold ${task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral'}`}>{task.aiResult.score}</span>
</div>
</CardContent></Card>
{task.aiResult.hardViolations.length > 0 && (
<Card>
<CardHeader className="pb-2"><CardTitle className="flex items-center gap-2 text-base"><Shield size={16} className="text-red-500" /> ({task.aiResult.hardViolations.length})</CardTitle></CardHeader>
<CardContent className="space-y-2">
{task.aiResult.hardViolations.map((v, idx) => (
<div key={idx} className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div className="flex items-center gap-2 mb-1"><ErrorTag>{v.type}</ErrorTag><span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</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>
))}
</CardContent>
</Card>
)}
{task.aiResult.sentimentWarnings.length > 0 && (
<Card>
<CardHeader className="pb-2"><CardTitle className="flex items-center gap-2 text-base"><Radio size={16} className="text-orange-500" /></CardTitle></CardHeader>
<CardContent className="space-y-2">
{task.aiResult.sentimentWarnings.map((w, idx) => (
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
<div className="flex items-center gap-2 mb-1"><WarningTag>{w.type}</WarningTag><span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span></div>
<p className="text-sm text-orange-400">{w.content}</p>
</div>
))}
</CardContent>
</Card>
)}
{task.aiResult.sellingPointsCovered.length > 0 && (
<Card>
<CardHeader className="pb-2"><CardTitle className="flex items-center gap-2 text-base"><CheckCircle size={16} className="text-accent-green" /></CardTitle></CardHeader>
<CardContent className="space-y-2">
{task.aiResult.sellingPointsCovered.map((sp, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
<div className="flex items-center gap-2">
{sp.covered ? <CheckCircle size={16} className="text-accent-green" /> : <XCircle size={16} className="text-accent-coral" />}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
{sp.covered && sp.timestamp && <span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>}
</div>
))}
</CardContent>
</Card>
)}
</div>
)
}
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() {
return (
<Card className="border-accent-green/30"><CardContent className="py-8 text-center">
<CheckCircle size={64} className="mx-auto text-accent-green mb-4" />
<h3 className="text-xl font-bold text-text-primary mb-2"></h3>
<p className="text-text-secondary mb-6"></p>
<div className="flex justify-center gap-3">
<Button variant="secondary"><Play size={16} /></Button>
<Button></Button>
</div>
</CardContent></Card>
)
}
// ========== 主页面 ==========
export default function CreatorVideoPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { subscribe } = useSSE()
const taskId = params.id as string
const [task, setTask] = useState<VideoTaskUI>(mockDefaultTask)
const [isLoading, setIsLoading] = useState(true)
const loadTask = useCallback(async () => {
if (USE_MOCK) { setIsLoading(false); return }
try {
const apiTask = await api.getTask(taskId)
setTask(mapApiToVideoUI(apiTask))
} catch { 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.videoStatus !== 'ai_reviewing' || USE_MOCK) return
const interval = setInterval(() => { loadTask() }, 5000)
return () => clearInterval(interval)
}, [task.videoStatus, loadTask])
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.videoStatus] || '未知状态'
}
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.videoStatus)} /></CardContent></Card>
{task.videoStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
{task.videoStatus === '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.videoStatus === 'ai_reviewing' && <AIReviewingSection />}
{task.videoStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
{task.videoStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
{task.videoStatus === '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.videoStatus === 'brand_reviewing' && task.agencyReview && (
<><ReviewFeedbackSection review={task.agencyReview} type="agency" /><AIResultSection task={task} /><WaitingSection message="等待品牌方终审" /></>
)}
{task.videoStatus === 'brand_passed' && task.agencyReview && task.brandReview && (
<><SuccessSection /><ReviewFeedbackSection review={task.brandReview} type="brand" />
<ReviewFeedbackSection review={task.agencyReview} type="agency" /><AIResultSection task={task} /></>
)}
{task.videoStatus === '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>
)
}