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

587 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, useCallback } from 'react'
import Link from 'next/link'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import {
FileText,
Video,
Search,
Clock,
Eye,
File,
Download,
MessageSquareWarning,
Loader2
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse } from '@/types/task'
function platformLabel(id?: string | null): string {
if (!id) return ''
return getPlatformInfo(id)?.name || id
}
// ==================== Mock 数据 ====================
const mockScriptTasks: TaskResponse[] = [
{
id: 'script-001', name: '夏日护肤推广脚本', sequence: 1,
stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-001', name: '小美护肤' },
script_file_name: '夏日护肤推广_脚本v2.docx',
script_ai_score: 88,
script_ai_result: { score: 88, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T14:30:00Z', updated_at: '2026-02-06T14:30:00Z',
},
{
id: 'script-002', name: '新品口红试色脚本', sequence: 2,
stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-002', name: '美妆Lisa' },
script_file_name: '口红试色_脚本v1.docx',
script_ai_score: 72,
script_ai_result: { score: 72, violations: [{ type: '违禁词', content: '最好', severity: 'medium', suggestion: '替换' }], soft_warnings: [] },
appeal_count: 1, is_appeal: true, appeal_reason: '已修改违规用词,请求重新审核',
created_at: '2026-02-06T12:15:00Z', updated_at: '2026-02-06T12:15:00Z',
},
{
id: 'script-003', name: '健身器材推荐脚本', sequence: 3,
stage: 'script_agency_review',
project: { id: 'proj-002', name: 'XX运动品牌', brand_name: 'XX运动' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-003', name: '健身教练王' },
script_file_name: '健身器材_推荐脚本.pdf',
script_ai_score: 95,
script_ai_result: { score: 95, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
},
]
const mockVideoTasks: TaskResponse[] = [
{
id: 'video-001', name: '夏日护肤推广', sequence: 1,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-001', name: '小美护肤' },
video_file_name: '夏日护肤_成片v2.mp4',
video_duration: 135, video_ai_score: 85,
video_ai_result: { score: 85, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T15:00:00Z', updated_at: '2026-02-06T15:00:00Z',
},
{
id: 'video-002', name: '新品口红试色', sequence: 2,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-002', name: '美妆Lisa' },
video_file_name: '口红试色_终版.mp4',
video_duration: 222, video_ai_score: 68,
video_ai_result: { score: 68, violations: [{ type: '竞品', content: '疑似竞品', severity: 'high', suggestion: '确认' }], soft_warnings: [] },
appeal_count: 1, is_appeal: true, appeal_reason: '已按要求重新剪辑,删除了争议片段',
created_at: '2026-02-06T13:45:00Z', updated_at: '2026-02-06T13:45:00Z',
},
{
id: 'video-003', name: '美妆新品体验', sequence: 3,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-003', name: '达人C' },
video_file_name: '美妆体验_v3.mp4',
video_duration: 260, video_ai_score: 58,
video_ai_result: { score: 58, violations: [{ type: '违禁词', content: '最好', severity: 'high', suggestion: '替换' }], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T11:30:00Z', updated_at: '2026-02-06T11:30:00Z',
},
]
// ==================== 工具函数 ====================
function getRiskLevel(task: TaskResponse, type: 'script' | 'video'): 'low' | 'medium' | 'high' {
const score = type === 'script' ? task.script_ai_score : task.video_ai_score
if (score == null) return 'low'
if (score >= 85) return 'low'
if (score >= 70) return 'medium'
return 'high'
}
const riskLevelConfig = {
low: { label: 'AI通过', color: 'bg-accent-green', textColor: 'text-accent-green' },
medium: { label: '风险:中', color: 'bg-accent-amber', textColor: 'text-accent-amber' },
high: { label: '风险:高', color: 'bg-accent-coral', textColor: 'text-accent-coral' },
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
function ScoreTag({ score }: { score: number }) {
if (score >= 85) return <SuccessTag>{score}</SuccessTag>
if (score >= 70) return <WarningTag>{score}</WarningTag>
return <ErrorTag>{score}</ErrorTag>
}
// ==================== 卡片组件 ====================
function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType<typeof useToast> }) {
const riskLevel = getRiskLevel(task, 'script')
const riskConfig = riskLevelConfig[riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.script_file_name || '脚本文件'}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
{/* 顶部条 */}
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || ''}</span>
{task.project.platform && (
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
)}
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
</div>
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
<p className="text-xs text-text-secondary mb-3">{task.creator.name}</p>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</div>
)}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.script_file_name || '脚本文件'}</p>
</div>
<button type="button" onClick={handlePreview} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="预览文件">
<Eye size={18} className="text-text-secondary" />
</button>
<button type="button" onClick={handleDownload} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="下载文件">
<Download size={18} className="text-text-secondary" />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.is_appeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType<typeof useToast> }) {
const riskLevel = getRiskLevel(task, 'video')
const riskConfig = riskLevelConfig[riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.video_file_name || '视频文件'}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || ''}</span>
{task.project.platform && (
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
)}
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
</div>
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
<p className="text-xs text-text-secondary mb-3">{task.creator.name}</p>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</div>
)}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.video_file_name || '视频文件'}</p>
{task.video_duration && (
<p className="text-xs text-text-tertiary">{formatDuration(task.video_duration)}</p>
)}
</div>
<button type="button" onClick={handlePreview} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="预览视频">
<Eye size={18} className="text-text-secondary" />
</button>
<button type="button" onClick={handleDownload} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="下载文件">
<Download size={18} className="text-text-secondary" />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.is_appeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
// ==================== 骨架屏 ====================
function ReviewListSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 w-24 bg-bg-elevated rounded" />
<div className="flex gap-2">
<div className="h-8 w-20 bg-bg-elevated rounded" />
<div className="h-8 w-20 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-10 w-full max-w-md bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[1, 2].map(i => (
<div key={i} className="space-y-3">
<div className="h-8 w-32 bg-bg-elevated rounded" />
{[1, 2, 3].map(j => (
<div key={j} className="h-40 bg-bg-elevated rounded-xl" />
))}
</div>
))}
</div>
</div>
)
}
// ==================== 主页面 ====================
export default function AgencyReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewTask, setPreviewTask] = useState<TaskResponse | null>(null)
const [previewType, setPreviewType] = useState<'script' | 'video'>('script')
const [scriptTasks, setScriptTasks] = useState<TaskResponse[]>([])
const [videoTasks, setVideoTasks] = useState<TaskResponse[]>([])
const [loading, setLoading] = useState(true)
const toast = useToast()
const { subscribe } = useSSE()
const loadData = useCallback(async () => {
if (USE_MOCK) {
setScriptTasks(mockScriptTasks)
setVideoTasks(mockVideoTasks)
setLoading(false)
return
}
try {
const [scriptData, videoData] = await Promise.all([
api.listTasks(1, 50, 'script_agency_review'),
api.listTasks(1, 50, 'video_agency_review'),
])
setScriptTasks(scriptData.items)
setVideoTasks(videoData.items)
} catch (err) {
console.error('Failed to load review tasks:', err)
toast.error('加载审核任务失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
const unsub1 = subscribe('task_updated', () => loadData())
const unsub2 = subscribe('new_task', () => loadData())
return () => { unsub1(); unsub2() }
}, [subscribe, loadData])
if (loading) return <ReviewListSkeleton />
const filteredScripts = scriptTasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creator.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredVideos = videoTasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creator.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const appealScriptCount = scriptTasks.filter(t => t.is_appeal).length
const appealVideoCount = videoTasks.filter(t => t.is_appeal).length
const handleScriptPreview = (task: TaskResponse) => {
setPreviewTask(task)
setPreviewType('script')
}
const handleVideoPreview = (task: TaskResponse) => {
setPreviewTask(task)
setPreviewType('video')
}
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-text-secondary"></span>
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
{scriptTasks.length}
</span>
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{videoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索任务名称或达人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setActiveTab('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('script')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'script' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('video')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'video' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
</div>
{/* 任务列表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{(activeTab === 'all' || activeTab === 'script') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredScripts.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<ScriptTaskCard key={task.id} task={task} onPreview={handleScriptPreview} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<FileText size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
{(activeTab === 'all' || activeTab === 'video') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video size={18} className="text-purple-400" />
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredVideos.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<VideoTaskCard key={task.id} task={task} onPreview={handleVideoPreview} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<Video size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 预览弹窗 */}
<Modal
isOpen={!!previewTask}
onClose={() => setPreviewTask(null)}
title={previewType === 'script' ? (previewTask?.script_file_name || '脚本预览') : (previewTask?.video_file_name || '视频预览')}
size="lg"
>
<div className="space-y-4">
{previewTask?.is_appeal && previewTask?.appeal_reason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewTask.appeal_reason}</p>
</div>
)}
{previewType === 'script' ? (
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 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="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
)}
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewType === 'script' ? previewTask?.script_file_name : previewTask?.video_file_name}</span>
{previewType === 'video' && previewTask?.video_duration && (
<>
<span className="mx-2">·</span>
<span>{formatDuration(previewTask.video_duration)}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewTask(null)}>
</Button>
<Button onClick={() => toast.info(`下载文件: ${previewType === 'script' ? previewTask?.script_file_name : previewTask?.video_file_name}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}