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

510 lines
20 KiB
TypeScript
Raw Permalink 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 {
ArrowLeft,
FileText,
Download,
Eye,
Target,
Ban,
File,
Building2,
Calendar,
Clock,
ChevronRight,
Loader2
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { BriefResponse } from '@/types/brief'
import type { TaskResponse } from '@/types/task'
// 代理商Brief文档类型
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
url?: string
}
// 页面视图模型
type BriefViewModel = {
taskName: string
agencyName: string
brandName: string
deadline: string
createdAt: string
files: AgencyBriefFile[]
sellingPoints: { id: string; content: string; required: boolean }[]
blacklistWords: { id: string; word: string; reason: string }[]
contentRequirements: string[]
}
// 模拟任务数据
const mockTaskInfo = {
id: 'task-001',
taskName: 'XX品牌618推广',
agencyName: '星辰传媒',
brandName: 'XX护肤品牌',
deadline: '2026-06-18',
createdAt: '2026-02-08',
}
// 模拟代理商Brief数据
const mockAgencyBrief = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
{ id: 'af4', name: '产品素材包.zip', size: '15.6MB', uploadedAt: '2026-02-02', description: '产品图片、视频素材等' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
{ id: 'bw5', word: '100%', reason: '虚假宣传' },
],
contentRequirements: [
'视频时长60-90秒',
'需展示产品质地和使用效果',
'需在户外或阳光下拍摄',
'需提及产品核心卖点',
],
}
function buildMockViewModel(): BriefViewModel {
return {
taskName: mockTaskInfo.taskName,
agencyName: mockTaskInfo.agencyName,
brandName: mockTaskInfo.brandName,
deadline: mockTaskInfo.deadline,
createdAt: mockTaskInfo.createdAt,
files: mockAgencyBrief.files,
sellingPoints: mockAgencyBrief.sellingPoints,
blacklistWords: mockAgencyBrief.blacklistWords,
contentRequirements: mockAgencyBrief.contentRequirements,
}
}
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
// 优先显示代理商上传的文档,没有则降级到品牌方附件
const agencyAtts = brief.agency_attachments ?? []
const brandAtts = brief.attachments ?? []
const sourceAtts = agencyAtts.length > 0 ? agencyAtts : brandAtts
const files: AgencyBriefFile[] = sourceAtts.map((att, idx) => ({
id: att.id || `att-${idx}`,
name: att.name,
size: att.size || '',
uploadedAt: brief.updated_at?.split('T')[0] || '',
description: undefined,
url: att.url,
}))
// Map selling points
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
id: `sp-${idx}`,
content: sp.content,
required: sp.required ?? (sp.priority === 'core'),
}))
// Map blacklist words
const blacklistWords = (brief.blacklist_words ?? []).map((bw, idx) => ({
id: `bw-${idx}`,
word: bw.word,
reason: bw.reason,
}))
// Build content requirements
const contentRequirements: string[] = []
if (brief.min_duration != null || brief.max_duration != null) {
const minStr = brief.min_duration != null ? `${brief.min_duration}` : '?'
const maxStr = brief.max_duration != null ? `${brief.max_duration}` : '?'
contentRequirements.push(`视频时长:${minStr}-${maxStr}`)
}
if (brief.other_requirements) {
contentRequirements.push(brief.other_requirements)
}
return {
taskName: task.name,
agencyName: task.agency.name,
brandName: task.project.brand_name || task.project.name,
deadline: '', // backend task has no deadline field yet
createdAt: task.created_at.split('T')[0],
files,
sellingPoints,
blacklistWords,
contentRequirements,
}
}
// 骨架屏
function BriefSkeleton() {
return (
<div className="flex flex-col gap-6 h-full animate-pulse">
{/* 顶部导航骨架 */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-2">
<div className="h-8 w-16 bg-bg-elevated rounded-lg" />
<div className="h-7 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-36 bg-bg-elevated rounded" />
</div>
<div className="h-10 w-28 bg-bg-elevated rounded-xl" />
</div>
{/* 任务信息骨架 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="h-5 w-24 bg-bg-elevated rounded mb-4" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-bg-elevated" />
<div className="flex flex-col gap-1">
<div className="h-3 w-12 bg-bg-elevated rounded" />
<div className="h-4 w-20 bg-bg-elevated rounded" />
</div>
</div>
))}
</div>
</div>
{/* 内容区域骨架 */}
<div className="flex-1 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="h-5 w-32 bg-bg-elevated rounded mb-4" />
<div className="space-y-2">
<div className="h-4 w-full bg-bg-elevated rounded" />
<div className="h-4 w-3/4 bg-bg-elevated rounded" />
<div className="h-4 w-1/2 bg-bg-elevated rounded" />
</div>
</div>
))}
</div>
</div>
)
}
export default function TaskBriefPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const [loading, setLoading] = useState(true)
const [viewModel, setViewModel] = useState<BriefViewModel | null>(null)
const loadBriefData = useCallback(async () => {
if (USE_MOCK) {
setViewModel(buildMockViewModel())
setLoading(false)
return
}
try {
setLoading(true)
// First get the task to find its project ID
const task = await api.getTask(taskId)
// Then get the brief for that project
const brief = await api.getBrief(task.project.id)
setViewModel(buildViewModelFromAPI(task, brief))
} catch (err) {
const message = err instanceof Error ? err.message : '加载Brief失败'
toast.error(message)
console.error('加载Brief失败:', err)
// Fallback: still show task info if brief load fails
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadBriefData()
}, [loadBriefData])
const handleDownload = async (file: AgencyBriefFile) => {
if (USE_MOCK || !file.url) {
toast.info(`下载文件: ${file.name}`)
return
}
try {
await api.downloadFile(file.url, file.name)
} catch {
toast.error('下载失败')
}
}
const handleDownloadAll = () => {
if (!viewModel) return
viewModel.files.forEach(f => handleDownload(f))
}
if (loading || !viewModel) {
return (
<ResponsiveLayout role="creator">
<BriefSkeleton />
</ResponsiveLayout>
)
}
const requiredPoints = viewModel.sellingPoints.filter(sp => sp.required)
const optionalPoints = viewModel.sellingPoints.filter(sp => !sp.required)
return (
<ResponsiveLayout role="creator">
<div className="flex flex-col gap-6 h-full">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3 mb-1">
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{viewModel.taskName}</h1>
<p className="text-sm lg:text-[15px] text-text-secondary">Brief文档</p>
</div>
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* 任务基本信息 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<h3 className="text-base font-semibold text-text-primary mb-4"></h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.agencyName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-accent-indigo" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.brandName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-green/15 flex items-center justify-center">
<Calendar className="w-5 h-5 text-accent-green" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.createdAt}</p>
</div>
</div>
{viewModel.deadline && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<Clock className="w-5 h-5 text-accent-coral" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{viewModel.deadline}</p>
</div>
</div>
)}
</div>
</div>
{/* 主要内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto space-y-6">
{/* Brief文档列表 */}
{viewModel.files.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({viewModel.files.length})</span>
</div>
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{viewModel.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 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>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* 内容要求 */}
{viewModel.contentRequirements.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<ul className="space-y-2">
{viewModel.contentRequirements.map((req, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
{req}
</li>
))}
</ul>
</div>
)}
{/* 卖点要求 */}
{viewModel.sellingPoints.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* 违禁词 */}
{viewModel.blacklistWords.length > 0 && (
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
</div>
<div className="flex flex-wrap gap-2">
{viewModel.blacklistWords.map((bw) => (
<span
key={bw.id}
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
)}
{/* 底部操作按钮 */}
<div className="flex justify-center py-4">
<Button size="lg" onClick={() => router.push(`/creator/task/${params.id}`)}>
<ChevronRight className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* 文件预览弹窗 */}
<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 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="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Modal>
</ResponsiveLayout>
)
}