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

440 lines
19 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 Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
AlertTriangle,
Clock,
CheckCircle,
ChevronRight,
FileVideo,
MessageSquare,
TrendingUp,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { AgencyDashboard as AgencyDashboardType } from '@/types/dashboard'
import type { TaskResponse } from '@/types/task'
import type { ProjectResponse } from '@/types/project'
// ==================== Mock 数据 ====================
const mockStats: AgencyDashboardType = {
pending_review: { script: 8, video: 4 },
pending_appeal: 3,
today_passed: { script: 18, video: 10 },
in_progress: { script: 25, video: 20 },
total_creators: 15,
total_tasks: 80,
}
const mockPendingTasks: TaskResponse[] = [
{
id: 'task-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_ai_score: 85, appeal_count: 0, is_appeal: false,
created_at: '2026-02-04T14:30:00Z', updated_at: '2026-02-04T14:30:00Z',
},
{
id: 'task-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_ai_score: 72, appeal_count: 0, is_appeal: true,
created_at: '2026-02-04T13:45:00Z', updated_at: '2026-02-04T13:45:00Z',
},
{
id: 'task-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_ai_score: 68, appeal_count: 0, is_appeal: false,
created_at: '2026-02-04T14:50:00Z', updated_at: '2026-02-04T14:50:00Z',
},
]
const mockProjects: ProjectResponse[] = [
{
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-06-18', agencies: [], task_count: 20,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-03-15', agencies: [], task_count: 12,
created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-002', brand_name: 'YY品牌',
status: 'active', deadline: '2026-09-01', agencies: [], task_count: 15,
created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
},
]
// ==================== 组件 ====================
function UrgentLevelIcon({ level }: { level: string }) {
if (level === 'high') return <AlertTriangle size={16} className="text-red-500" />
if (level === 'medium') return <MessageSquare size={16} className="text-orange-500" />
return <CheckCircle size={16} className="text-yellow-500" />
}
function getTaskUrgencyLevel(task: TaskResponse): string {
const aiScore = task.stage.startsWith('script') ? task.script_ai_score : task.video_ai_score
if (aiScore != null && aiScore < 60) return 'high'
if (task.is_appeal) return 'medium'
return 'low'
}
function getTaskUrgencyTitle(task: TaskResponse): string {
return `${task.project.name} · ${task.name}`
}
function getPlatformLabel(platform?: string | null): string {
const map: Record<string, string> = { douyin: '抖音', xiaohongshu: '小红书', bilibili: 'B站', kuaishou: '快手' }
return platform ? (map[platform] || platform) : ''
}
function getTaskTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000)
if (hours < 1) return `${Math.floor(diff / 60000)}分钟前`
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 w-40 bg-bg-elevated rounded" />
<div className="h-5 w-48 bg-bg-elevated rounded" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-28 bg-bg-elevated rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="h-64 bg-bg-elevated rounded-xl" />
<div className="lg:col-span-2 h-64 bg-bg-elevated rounded-xl" />
</div>
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
)
}
export default function AgencyDashboard() {
const [stats, setStats] = useState<AgencyDashboardType | null>(null)
const [pendingTasks, setPendingTasks] = useState<TaskResponse[]>([])
const [projects, setProjects] = useState<ProjectResponse[]>([])
const [loading, setLoading] = useState(true)
const { subscribe } = useSSE()
const loadData = useCallback(async () => {
if (USE_MOCK) {
setStats(mockStats)
setPendingTasks(mockPendingTasks)
setProjects(mockProjects)
setLoading(false)
return
}
try {
const [dashboardData, tasksData, projectsData] = await Promise.all([
api.getAgencyDashboard(),
api.listPendingReviews(1, 5),
api.listProjects(1, 3),
])
setStats(dashboardData)
// listPendingReviews returns TaskSummary, but we need TaskResponse for the table
// Fall back to listTasks for the pending table
const fullTasks = await api.listTasks(1, 5, 'script_agency_review')
setPendingTasks(fullTasks.items)
setProjects(projectsData.items)
} catch (err) {
console.error('Failed to load agency dashboard:', err)
// fallback to empty
setStats({ pending_review: { script: 0, video: 0 }, pending_appeal: 0, today_passed: { script: 0, video: 0 }, in_progress: { script: 0, video: 0 }, total_creators: 0, total_tasks: 0 })
setPendingTasks([])
setProjects([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
const unsub1 = subscribe('task_updated', () => loadData())
const unsub2 = subscribe('new_task', () => loadData())
const unsub3 = subscribe('review_completed', () => loadData())
return () => { unsub1(); unsub2(); unsub3() }
}, [subscribe, loadData])
if (loading || !stats) return <DashboardSkeleton />
// Build urgent todos from pending tasks (top 3)
const urgentTodos = pendingTasks.slice(0, 3).map(task => {
const type = task.stage.includes('video') ? '视频' : '脚本'
const platformLabel = getPlatformLabel(task.project.platform)
const brandLabel = task.project.brand_name || ''
const desc = [task.creator.name, brandLabel, platformLabel, type].filter(Boolean).join(' · ')
return {
id: task.id,
title: getTaskUrgencyTitle(task),
description: desc,
time: getTaskTimeAgo(task.updated_at),
level: getTaskUrgencyLevel(task),
}
})
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-text-primary"></h1>
<div className="text-sm text-text-secondary">{new Date().toLocaleString('zh-CN')}</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-accent-coral/20 to-bg-card border-accent-coral/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-coral">{stats.pending_review.script + stats.pending_review.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.pending_review.script}</span>
<span> {stats.pending_review.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-coral/20 flex items-center justify-center">
<Clock size={24} className="text-accent-coral" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-500/20 to-bg-card border-orange-500/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-orange-400">{stats.pending_appeal}</div>
</div>
<div className="w-12 h-12 rounded-full bg-orange-500/20 flex items-center justify-center">
<MessageSquare size={24} className="text-orange-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent-green/20 to-bg-card border-accent-green/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-green">{stats.today_passed.script + stats.today_passed.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.today_passed.script}</span>
<span> {stats.today_passed.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-green/20 flex items-center justify-center">
<CheckCircle size={24} className="text-accent-green" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent-indigo/20 to-bg-card border-accent-indigo/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-indigo">{stats.in_progress.script + stats.in_progress.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.in_progress.script}</span>
<span> {stats.in_progress.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-indigo/20 flex items-center justify-center">
<FileVideo size={24} className="text-accent-indigo" />
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 紧急待办 */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-red-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{urgentTodos.length > 0 ? urgentTodos.map((todo) => (
<Link
key={todo.id}
href={`/agency/review/${todo.id}`}
className="block p-3 rounded-lg border border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-colors"
>
<div className="flex items-start gap-3">
<UrgentLevelIcon level={todo.level} />
<div className="flex-1 min-w-0">
<div className="font-medium text-text-primary truncate">{todo.title}</div>
<div className="text-sm text-text-secondary">{todo.description}</div>
<div className="text-xs text-text-tertiary mt-1">{todo.time}</div>
</div>
<ChevronRight size={16} className="text-text-tertiary flex-shrink-0" />
</div>
</Link>
)) : (
<div className="text-center py-6 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
{/* 项目概览 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp size={18} className="text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{projects.length > 0 ? projects.map((project) => (
<div key={project.id} className="p-4 rounded-lg bg-bg-elevated">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{project.name}</span>
{project.brand_name && (
<span className="text-xs text-text-tertiary">({project.brand_name})</span>
)}
{project.platform && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-indigo/10 text-accent-indigo">{getPlatformLabel(project.platform)}</span>
)}
</div>
<span className="text-sm text-text-secondary">
{project.task_count}
</span>
</div>
<div className="flex items-center justify-between text-xs text-text-tertiary">
<span>: {project.status === 'active' ? '进行中' : project.status === 'completed' ? '已完成' : '已归档'}</span>
{project.deadline && <span>: {new Date(project.deadline).toLocaleDateString('zh-CN')}</span>}
</div>
</div>
)) : (
<div className="text-center py-6 text-text-tertiary text-sm"></div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 待审核列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<Link href="/agency/review">
<Button variant="ghost" size="sm">
<ChevronRight size={16} />
</Button>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<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>
<th className="pb-3 font-medium">AI评分</th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{pendingTasks.length > 0 ? pendingTasks.map((task) => {
const isVideo = task.stage.includes('video')
const aiScore = isVideo ? task.video_ai_score : task.script_ai_score
return (
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
<td className="py-4">
<div className="flex items-center gap-2">
<div>
<div className="font-medium text-text-primary">{task.project.name} · {task.name}</div>
</div>
{task.is_appeal && (
<span className="px-1.5 py-0.5 text-xs bg-accent-amber/20 text-accent-amber rounded">
</span>
)}
</div>
</td>
<td className="py-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
isVideo ? 'bg-purple-500/20 text-purple-400' : 'bg-accent-indigo/20 text-accent-indigo'
}`}>
{isVideo ? '视频' : '脚本'}
</span>
</td>
<td className="py-4 text-text-secondary">{task.creator.name}</td>
<td className="py-4 text-text-secondary">{task.project.brand_name || '-'}</td>
<td className="py-4 text-text-secondary">{getPlatformLabel(task.project.platform) || '-'}</td>
<td className="py-4">
{aiScore != null ? (
<span className={`font-medium ${
aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'
}`}>
{aiScore}
</span>
) : (
<span className="text-text-tertiary">-</span>
)}
</td>
<td className="py-4 text-sm text-text-tertiary">
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="py-4">
<Link href={`/agency/review/${task.id}`}>
<Button size="sm"></Button>
</Link>
</td>
</tr>
)
}) : (
<tr>
<td colSpan={8} className="py-8 text-center text-text-tertiary"></td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}