Your Name 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

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

419 lines
20 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
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'
switch (stage) {
case 'video_upload': status = '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,
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_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_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>
)
}