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>
This commit is contained in:
Your Name
2026-02-11 19:11:54 +08:00
co-authored by Claude Opus 4.6
parent 0c59797d5b
commit 0ef7650c09
43 changed files with 3909 additions and 1316 deletions
+213 -43
View File
@@ -14,7 +14,7 @@ import {
Calendar,
Users,
FileText,
Video,
Clock,
CheckCircle,
XCircle,
@@ -30,6 +30,7 @@ import {
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { mapTaskToUI } from '@/lib/taskStageMapper'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
@@ -77,6 +78,24 @@ const mockTasks: TaskResponse[] = [
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
},
{
id: 'task-004', name: '美白精华测评', sequence: 4,
stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG456789', name: '创意无限' },
creator: { id: 'cr-004', name: '时尚小王' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-07T09:00:00Z', updated_at: '2026-02-07T09:00:00Z',
},
{
id: 'task-005', name: '防晒霜种草', sequence: 5,
stage: 'script_upload',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG789012', name: '星耀传媒' },
creator: { id: 'cr-001', name: '小美护肤' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-07T11:00:00Z', updated_at: '2026-02-07T11:00:00Z',
},
]
const mockManagedAgencies: AgencyDetail[] = [
@@ -136,6 +155,137 @@ function DetailSkeleton() {
)
}
// ==================== 任务进度条 ====================
const SCRIPT_STEPS = [
{ key: 'script_upload', label: '上传' },
{ key: 'script_ai_review', label: 'AI' },
{ key: 'script_agency_review', label: '代理商' },
{ key: 'script_brand_review', label: '品牌' },
]
const VIDEO_STEPS = [
{ key: 'video_upload', label: '上传' },
{ key: 'video_ai_review', label: 'AI' },
{ key: 'video_agency_review', label: '代理商' },
{ key: 'video_brand_review', label: '品牌' },
]
function StepDot({ status }: { status: 'done' | 'current' | 'error' | 'pending' }) {
const base = 'w-3 h-3 rounded-full border-2 flex-shrink-0'
if (status === 'done') return <div className={`${base} bg-accent-green border-accent-green`} />
if (status === 'current') return <div className={`${base} bg-accent-indigo border-accent-indigo animate-pulse`} />
if (status === 'error') return <div className={`${base} bg-accent-coral border-accent-coral`} />
return <div className={`${base} bg-transparent border-border-strong`} />
}
function StepLine({ status }: { status: 'done' | 'pending' | 'error' }) {
if (status === 'done') return <div className="w-4 h-0.5 bg-accent-green mx-0.5" />
if (status === 'error') return <div className="w-4 h-0.5 bg-accent-coral mx-0.5" />
return <div className="w-4 h-0.5 bg-border-strong mx-0.5" />
}
function TaskProgressBar({ task }: { task: TaskResponse }) {
const ui = mapTaskToUI(task)
const scriptStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
ui.scriptStage.submit, ui.scriptStage.ai, ui.scriptStage.agency, ui.scriptStage.brand,
]
const videoStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
ui.videoStage.submit, ui.videoStage.ai, ui.videoStage.agency, ui.videoStage.brand,
]
const isCompleted = task.stage === 'completed'
const isRejected = task.stage === 'rejected'
return (
<div className="flex items-center gap-1">
{/* 脚本阶段 */}
<div className="flex items-center gap-0">
<span className="text-[10px] text-text-tertiary mr-1.5 w-6"></span>
{SCRIPT_STEPS.map((step, i) => (
<div key={step.key} className="flex items-center">
<div className="relative group">
<StepDot status={scriptStatuses[i]} />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
{step.label}
</div>
</div>
{i < SCRIPT_STEPS.length - 1 && (
<StepLine status={scriptStatuses[i] === 'done' ? 'done' : scriptStatuses[i] === 'error' ? 'error' : 'pending'} />
)}
</div>
))}
</div>
{/* 分隔线 */}
<div className="w-3 h-0.5 bg-border-subtle mx-0.5" />
{/* 视频阶段 */}
<div className="flex items-center gap-0">
<span className="text-[10px] text-text-tertiary mr-1.5 w-6"></span>
{VIDEO_STEPS.map((step, i) => (
<div key={step.key} className="flex items-center">
<div className="relative group">
<StepDot status={videoStatuses[i]} />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
{step.label}
</div>
</div>
{i < VIDEO_STEPS.length - 1 && (
<StepLine status={videoStatuses[i] === 'done' ? 'done' : videoStatuses[i] === 'error' ? 'error' : 'pending'} />
)}
</div>
))}
</div>
{/* 完成标记 */}
<div className="ml-1.5">
{isCompleted ? (
<CheckCircle size={14} className="text-accent-green" />
) : isRejected ? (
<XCircle size={14} className="text-accent-coral" />
) : (
<div className="w-3.5 h-3.5" />
)}
</div>
</div>
)
}
interface TaskGroup {
agencyId: string
agencyName: string
creators: {
creatorId: string
creatorName: string
tasks: TaskResponse[]
}[]
}
function groupTasksByAgencyCreator(tasks: TaskResponse[]): TaskGroup[] {
const agencyMap = new Map<string, TaskGroup>()
for (const task of tasks) {
if (!agencyMap.has(task.agency.id)) {
agencyMap.set(task.agency.id, {
agencyId: task.agency.id,
agencyName: task.agency.name,
creators: [],
})
}
const group = agencyMap.get(task.agency.id)!
let creator = group.creators.find(c => c.creatorId === task.creator.id)
if (!creator) {
creator = { creatorId: task.creator.id, creatorName: task.creator.name, tasks: [] }
group.creators.push(creator)
}
creator.tasks.push(task)
}
return Array.from(agencyMap.values())
}
export default function ProjectDetailPage() {
const router = useRouter()
const params = useParams()
@@ -144,7 +294,7 @@ export default function ProjectDetailPage() {
const { subscribe } = useSSE()
const [project, setProject] = useState<ProjectResponse | null>(null)
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([])
const [allTasks, setAllTasks] = useState<TaskResponse[]>([])
const [managedAgencies, setManagedAgencies] = useState<AgencyDetail[]>([])
const [loading, setLoading] = useState(true)
@@ -162,7 +312,7 @@ export default function ProjectDetailPage() {
const loadData = useCallback(async () => {
if (USE_MOCK) {
setProject(mockProject)
setRecentTasks(mockTasks)
setAllTasks(mockTasks)
setManagedAgencies(mockManagedAgencies)
setLoading(false)
return
@@ -171,11 +321,11 @@ export default function ProjectDetailPage() {
try {
const [projectData, tasksData, agenciesData] = await Promise.all([
api.getProject(projectId),
api.listTasks(1, 10),
api.listTasks(1, 100, undefined, projectId),
api.listBrandAgencies(),
])
setProject(projectData)
setRecentTasks(tasksData.items.filter(t => t.project.id === projectId).slice(0, 5))
setAllTasks(tasksData.items)
setManagedAgencies(agenciesData.items)
} catch (err) {
console.error('Failed to load project:', err)
@@ -337,54 +487,74 @@ export default function ProjectDetailPage() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 最近任务 */}
{/* 任务进度 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<span></span>
<Link href="/brand/review">
<Button variant="ghost" size="sm">
<ChevronRight size={16} />
<ChevronRight size={16} />
</Button>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
{recentTasks.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{recentTasks.map((task) => (
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
<td className="py-4 font-medium text-text-primary">{task.name}</td>
<td className="py-4 text-text-secondary">{task.creator.name}</td>
<td className="py-4">
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-bg-elevated text-sm">
<Building2 size={14} className="text-accent-indigo" />
<span className="text-text-secondary">{task.agency.name}</span>
</span>
</td>
<td className="py-4"><TaskStatusTag stage={task.stage} /></td>
<td className="py-4">
<Link href={`/agency/review/${task.id}`}>
<Button size="sm" variant={task.stage.includes('review') ? 'primary' : 'secondary'}>
{task.stage.includes('review') ? '审核' : '查看'}
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
{allTasks.length > 0 ? (
<div className="space-y-4">
{/* 图例 */}
<div className="flex items-center gap-4 text-[10px] text-text-tertiary pb-2 border-b border-border-subtle">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-green inline-block" /> </span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-indigo inline-block" /> </span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full border border-border-strong inline-block" /> </span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-coral inline-block" /> </span>
</div>
{groupTasksByAgencyCreator(allTasks).map((group) => (
<div key={group.agencyId} className="space-y-2">
{/* 代理商标题 */}
<div className="flex items-center gap-2">
<Building2 size={14} className="text-accent-indigo" />
<span className="text-sm font-medium text-text-primary">{group.agencyName}</span>
<span className="text-xs text-text-tertiary">
({group.creators.reduce((sum, c) => sum + c.tasks.length, 0)} )
</span>
</div>
{/* 达人列表 */}
<div className="ml-4 space-y-1">
{group.creators.map((creator) => (
<div key={creator.creatorId} className="space-y-1">
{/* 达人名称 */}
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-accent-green/15 flex items-center justify-center">
<Users size={10} className="text-accent-green" />
</div>
<span className="text-xs font-medium text-text-secondary">{creator.creatorName}</span>
</div>
{/* 任务进度条 */}
<div className="ml-6 space-y-1.5">
{creator.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-3 py-1.5 px-2 rounded-lg hover:bg-bg-elevated group/task">
<span className="text-xs text-text-primary min-w-[80px] truncate font-medium">{task.name}</span>
<TaskProgressBar task={task} />
<span className="text-[10px] text-text-tertiary ml-auto hidden group-hover/task:block">
<TaskStatusTag stage={task.stage} />
</span>
<Link href={task.stage.includes('video') ? `/brand/review/video/${task.id}` : `/brand/review/script/${task.id}`}>
<button type="button" className="text-xs text-accent-indigo hover:text-accent-indigo/80 opacity-0 group-hover/task:opacity-100 transition-opacity">
{task.stage.includes('brand_review') ? '审核' : '查看'}
</button>
</Link>
</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-text-tertiary text-sm"></div>