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
+135 -65
View File
@@ -25,6 +25,7 @@ import {
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { getPlatformInfo } from '@/lib/platforms'
import type { TaskResponse } from '@/types/task'
// 模拟脚本任务数据
@@ -81,6 +82,8 @@ function mapTaskToViewModel(task: TaskResponse) {
title: task.name,
creatorName: task.creator?.name || '未知达人',
projectName: task.project?.name || '未知项目',
brandName: task.project?.brand_name || '',
platform: task.project?.platform || '',
submittedAt: task.script_uploaded_at || task.created_at,
aiScore: task.script_ai_score ?? 0,
status: task.stage,
@@ -107,12 +110,26 @@ function mapTaskToViewModel(task: TaskResponse) {
content: v.content,
suggestion: v.suggestion,
severity: v.severity,
dimension: v.dimension,
})),
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w) => ({
item: w.type,
passed: false,
note: w.content,
})),
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w: any) => {
const codeLabels: Record<string, string> = {
missing_selling_points: '卖点缺失',
tone_mismatch: '语气不符',
length_warning: '时长提示',
style_warning: '风格提示',
sensitive_topic: '敏感话题',
audience_mismatch: '受众偏差',
}
const rawLabel = w.type || w.code || '提示'
return {
item: codeLabels[rawLabel] || rawLabel,
passed: false,
note: w.content || w.message || '',
}
}),
dimensions: task.script_ai_result?.dimensions,
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
},
aiSummary: task.script_ai_result?.summary || '',
@@ -183,6 +200,7 @@ export default function AgencyScriptReviewPage() {
const [showFilePreview, setShowFilePreview] = useState(false)
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
const loadTask = useCallback(async () => {
if (USE_MOCK) return
setLoading(true)
@@ -294,10 +312,9 @@ export default function AgencyScriptReviewPage() {
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
{task.creatorName}
</span>
<span>{task.creatorName}</span>
{task.brandName && <span>{task.brandName}</span>}
{task.platform && <span>{getPlatformInfo(task.platform)?.name || task.platform}</span>}
<span className="flex items-center gap-1">
<Clock size={14} />
{task.submittedAt}
@@ -368,8 +385,7 @@ export default function AgencyScriptReviewPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
AI
<span className="text-xs font-normal text-text-tertiary ml-2">AI </span>
AI
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -378,23 +394,41 @@ export default function AgencyScriptReviewPage() {
<div className="text-xs text-accent-indigo font-medium mb-2">AI </div>
<p className="text-text-primary">{task.aiSummary}</p>
</div>
) : null}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-indigo font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.opening || '(无内容)'}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-purple-400 font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.productIntro || '(无内容)'}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-orange-400 font-medium mb-2">使</div>
<p className="text-text-primary">{task.scriptContent.demo || '(无内容)'}</p>
</div>
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-green font-medium mb-2"></div>
<p className="text-text-primary">{task.scriptContent.closing || '(无内容)'}</p>
</div>
) : (
<p className="text-sm text-text-tertiary text-center py-4"> AI </p>
)}
{task.aiAnalysis.violations.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-coral font-medium mb-2"> ({task.aiAnalysis.violations.length})</div>
<div className="space-y-2">
{task.aiAnalysis.violations.map((v) => (
<div key={v.id} className="text-sm">
<span className="text-accent-coral font-medium">[{v.type}]</span>
<span className="text-text-primary ml-1">{v.content}</span>
<p className="text-xs text-accent-indigo mt-0.5">{v.suggestion}</p>
</div>
))}
</div>
</div>
)}
{task.aiAnalysis.sellingPointMatches.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-green font-medium mb-2"></div>
<div className="space-y-1">
{task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
<div key={idx} className="flex items-center gap-2 text-sm">
{sp.matched ? <CheckCircle size={14} className="text-accent-green flex-shrink-0" /> : <XCircle size={14} className="text-accent-coral flex-shrink-0" />}
<span className="text-text-primary">{sp.content}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
'bg-bg-page text-text-tertiary'
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
@@ -414,6 +448,34 @@ export default function AgencyScriptReviewPage() {
</CardContent>
</Card>
{/* 维度评分 */}
{task.aiAnalysis.dimensions && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
const dim = (task.aiAnalysis.dimensions as unknown as Record<string, { score: number; passed: boolean; issue_count: number }>)?.[key]
if (!dim) return null
const label = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[key]
return (
<div key={key} className={`flex items-center justify-between p-2 rounded-lg ${dim.passed ? 'bg-accent-green/5' : 'bg-accent-coral/5'}`}>
<span className="text-sm text-text-primary">{label}</span>
<div className="flex items-center gap-2">
<span className={`text-sm font-bold ${dim.passed ? 'text-accent-green' : 'text-accent-coral'}`}>{dim.score}</span>
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* 违规检测 */}
<Card>
<CardHeader className="pb-2">
@@ -427,6 +489,7 @@ export default function AgencyScriptReviewPage() {
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
<div className="flex items-center gap-2 mb-1">
<WarningTag>{v.type}</WarningTag>
{v.dimension && <span className="text-xs text-text-tertiary">{{ legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[v.dimension as string]}</span>}
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
@@ -438,53 +501,58 @@ export default function AgencyScriptReviewPage() {
</CardContent>
</Card>
{/* 合规检查 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.complianceChecks.map((check, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
{check.passed ? (
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
) : (
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span className="text-sm text-text-primary">{check.item}</span>
{/* 舆情提示 */}
{task.aiAnalysis.complianceChecks.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle size={16} className="text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.complianceChecks.map((check, idx) => (
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
<div className="flex items-center gap-2 mb-1">
<WarningTag>{check.item}</WarningTag>
</div>
{check.note && (
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
<p className="text-sm text-text-secondary">{check.note}</p>
)}
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
))}
</CardContent>
</Card>
))}
</CardContent>
</Card>
)}
{/* 卖点覆盖 */}
{/* 卖点匹配 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
))}
{task.aiAnalysis.sellingPoints.length === 0 && (
{task.aiAnalysis.sellingPointMatches && task.aiAnalysis.sellingPointMatches.length > 0 ? (
task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-text-primary">{sp.content}</span>
<span className={`px-1.5 py-0.5 text-xs rounded ${
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
'bg-bg-page text-text-tertiary'
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
</div>
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
</div>
</div>
))
) : (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
@@ -497,7 +565,9 @@ export default function AgencyScriptReviewPage() {
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="text-sm text-text-secondary">
{task.projectName}
{task.brandName && <span>{task.brandName} · </span>}
{task.projectName}
{task.platform && <span> · {getPlatformInfo(task.platform)?.name || task.platform}</span>}
</div>
<div className="flex gap-3">
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>