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:
co-authored by
Claude Opus 4.6
parent
0c59797d5b
commit
0ef7650c09
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user