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
+442 -103
View File
@@ -30,12 +30,14 @@ import {
Loader2,
Search,
AlertCircle,
RotateCcw
RotateCcw,
Users,
UserPlus
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import type { RuleConflict, ParsedRulesData } from '@/types/rules'
// 单个文件上传状态
interface UploadingFileItem {
@@ -49,6 +51,9 @@ interface UploadingFileItem {
}
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
import type { ProjectResponse } from '@/types/project'
import type { TaskResponse } from '@/types/task'
import type { CreatorDetail } from '@/types/organization'
import { mapTaskToUI } from '@/lib/taskStageMapper'
// 文件类型
type BriefFile = {
@@ -121,11 +126,11 @@ const mockAgencyConfig = {
},
// 代理商配置的卖点(可编辑)
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 },
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
{ id: 'sp4', content: '适合敏感肌', priority: 'reference' as const },
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
],
// 代理商配置的违禁词(可编辑)
blacklistWords: [
@@ -136,32 +141,36 @@ const mockAgencyConfig = {
],
}
// 平台规则
const platformRules = {
douyin: {
name: '抖音',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
{ category: '医疗相关禁用', items: ['治疗', '药用', '医学', '临床', '处方'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用', '立竿见影'] },
],
},
xiaohongshu: {
name: '小红书',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极品', '绝对'] },
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
],
},
bilibili: {
name: 'B站',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用'] },
],
},
// 平台规则类型
interface PlatformRuleCategory {
category: string
items: string[]
}
// 将后端 ParsedRulesData 转为 UI 展示格式
function parsedRulesToCategories(parsed: ParsedRulesData): PlatformRuleCategory[] {
const categories: PlatformRuleCategory[] = []
if (parsed.forbidden_words?.length) {
categories.push({ category: '违禁词', items: parsed.forbidden_words })
}
if (parsed.restricted_words?.length) {
categories.push({ category: '限制用语', items: parsed.restricted_words.map(w => w.word) })
}
if (parsed.content_requirements?.length) {
categories.push({ category: '内容要求', items: parsed.content_requirements })
}
if (parsed.other_rules?.length) {
categories.push({ category: '其他规则', items: parsed.other_rules.map(r => r.rule) })
}
return categories
}
// Mock 模式下的默认平台规则
const mockPlatformRules: PlatformRuleCategory[] = [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
]
// ==================== 工具函数 ====================
function formatFileSize(bytes: number): string {
@@ -225,6 +234,7 @@ export default function BriefConfigPage() {
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
const [minSellingPoints, setMinSellingPoints] = useState<number | null>(null)
// 弹窗状态
const [showFilesModal, setShowFilesModal] = useState(false)
@@ -236,6 +246,16 @@ export default function BriefConfigPage() {
const [isAIParsing, setIsAIParsing] = useState(false)
const isUploading = uploadingFiles.some(f => f.status === 'uploading')
// 动态平台规则
const [dynamicPlatformRules, setDynamicPlatformRules] = useState<PlatformRuleCategory[]>(mockPlatformRules)
const [platformRuleName, setPlatformRuleName] = useState('')
// 任务管理
const [projectTasks, setProjectTasks] = useState<TaskResponse[]>([])
const [availableCreators, setAvailableCreators] = useState<CreatorDetail[]>([])
const [showCreatorModal, setShowCreatorModal] = useState(false)
const [creatingTask, setCreatingTask] = useState(false)
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
@@ -263,6 +283,38 @@ export default function BriefConfigPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCreateTask = async (creatorId: string) => {
setCreatingTask(true)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
const creator = availableCreators.find(c => c.id === creatorId)
const seq = projectTasks.length + 1
setProjectTasks(prev => [...prev, {
id: `TK-mock-${Date.now()}`, name: `${brandBrief.projectName} #${seq}`, sequence: seq,
stage: 'script_upload',
project: { id: projectId, name: brandBrief.projectName },
agency: { id: 'AG000001', name: '星辰传媒' },
creator: { id: creatorId, name: creator?.name || '未知达人' },
appeal_count: 0, is_appeal: false,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}])
toast.success('任务创建成功')
setShowCreatorModal(false)
} else {
await api.createTask({ project_id: projectId, creator_id: creatorId })
const tasksResp = await api.listTasks(1, 100, undefined, projectId)
setProjectTasks(tasksResp.items)
toast.success('任务创建成功')
setShowCreatorModal(false)
}
} catch {
toast.error('创建任务失败')
} finally {
setCreatingTask(false)
}
}
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
@@ -315,6 +367,29 @@ export default function BriefConfigPage() {
const loadData = useCallback(async () => {
if (USE_MOCK) {
// Mock 模式使用默认数据
setProjectTasks([
{
id: 'TK000001', name: 'XX品牌618推广 #1', sequence: 1, stage: 'script_upload',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG000001', name: '星辰传媒' },
creator: { id: 'CR000001', name: '李小红' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-01T10:00:00', updated_at: '2026-02-01T10:00:00',
},
{
id: 'TK000002', name: 'XX品牌618推广 #2', sequence: 2, stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广' },
agency: { id: 'AG000001', name: '星辰传媒' },
creator: { id: 'CR000002', name: '张大力' },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-02T10:00:00', updated_at: '2026-02-03T10:00:00',
},
])
setAvailableCreators([
{ id: 'CR000001', name: '李小红', douyin_account: 'lixiaohong', xiaohongshu_account: null, bilibili_account: null },
{ id: 'CR000002', name: '张大力', douyin_account: 'zhangdali', xiaohongshu_account: 'zhangdali_xhs', bilibili_account: null },
{ id: 'CR000003', name: '王美丽', douyin_account: null, xiaohongshu_account: 'wangmeili', bilibili_account: null },
])
setLoading(false)
return
}
@@ -367,6 +442,11 @@ export default function BriefConfigPage() {
// 映射到代理商配置视图
const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone)
// 加载最少卖点数配置
if (brief?.min_selling_points != null) {
setMinSellingPoints(brief.min_selling_points)
}
setAgencyConfig({
status: hasBrief ? 'configured' : 'pending',
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
@@ -377,17 +457,25 @@ export default function BriefConfigPage() {
uploadedAt: brief!.updated_at?.split('T')[0] || '',
url: att.url,
})),
aiParsedContent: {
productName: brief?.brand_tone || '待解析',
targetAudience: '待解析',
contentRequirements: brief?.min_duration && brief?.max_duration
? `视频时长 ${brief.min_duration}-${brief.max_duration}`
: (brief?.other_requirements || '待解析'),
},
aiParsedContent: (() => {
// brand_tone 存储格式: "产品名称\n目标受众"
const toneParts = (brief?.brand_tone || '').split('\n')
const productName = toneParts[0] || ''
const targetAudience = toneParts[1] || ''
const contentRequirements = brief?.other_requirements || ''
return {
productName: productName || '待解析',
targetAudience: targetAudience || '待解析',
contentRequirements: contentRequirements
|| (brief?.min_duration && brief?.max_duration
? `视频时长 ${brief.min_duration}-${brief.max_duration}`
: '待解析'),
}
})(),
sellingPoints: (brief?.selling_points || []).map((sp, i) => ({
id: `sp-${i}`,
content: sp.content,
required: sp.required,
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
})),
blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({
id: `bw-${i}`,
@@ -395,6 +483,38 @@ export default function BriefConfigPage() {
reason: bw.reason,
})),
})
// 3. 获取平台规则
const platformKey = project.platform || 'douyin'
const platformInfo = getPlatformInfo(platformKey)
setPlatformRuleName(platformInfo?.name || platformKey)
try {
const rulesResp = await api.listBrandPlatformRules({ platform: platformKey, status: 'active' })
if (rulesResp.items.length > 0) {
const categories = parsedRulesToCategories(rulesResp.items[0].parsed_rules)
if (categories.length > 0) {
setDynamicPlatformRules(categories)
}
}
} catch (e) {
console.warn('获取平台规则失败,使用默认规则:', e)
}
// 4. 获取项目任务列表
try {
const tasksResp = await api.listTasks(1, 100, undefined, projectId)
setProjectTasks(tasksResp.items)
} catch (e) {
console.warn('获取项目任务列表失败:', e)
}
// 5. 获取可选达人列表
try {
const creatorsResp = await api.listAgencyCreators()
setAvailableCreators(creatorsResp.items)
} catch (e) {
console.warn('获取达人列表失败:', e)
}
} catch (err) {
console.error('加载 Brief 详情失败:', err)
toast.error('加载 Brief 详情失败')
@@ -408,7 +528,6 @@ export default function BriefConfigPage() {
}, [loadData])
const platform = getPlatformInfo(brandBrief.platform)
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
// 下载文件
const handleDownload = async (file: BriefFile) => {
@@ -417,17 +536,7 @@ export default function BriefConfigPage() {
return
}
try {
const signedUrl = await api.getSignedUrl(file.url)
const resp = await fetch(signedUrl)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
await api.downloadFile(file.url, file.name)
} catch {
toast.error('下载失败')
}
@@ -442,8 +551,8 @@ export default function BriefConfigPage() {
if (!USE_MOCK && file.url) {
setPreviewLoading(true)
try {
const signedUrl = await api.getSignedUrl(file.url)
setPreviewUrl(signedUrl)
const blobUrl = await api.getPreviewUrl(file.url)
setPreviewUrl(blobUrl)
} catch {
toast.error('获取预览链接失败')
} finally {
@@ -463,9 +572,68 @@ export default function BriefConfigPage() {
// AI 解析
const handleAIParse = async () => {
setIsAIParsing(true)
await new Promise(resolve => setTimeout(resolve, 2000))
setIsAIParsing(false)
toast.success('AI 解析完成!')
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 2000))
setIsAIParsing(false)
toast.success('AI 解析完成!')
return
}
try {
const result = await api.parseBrief(projectId)
// 更新 AI 解析结果
setAgencyConfig(prev => ({
...prev,
aiParsedContent: {
productName: result.product_name || prev.aiParsedContent.productName,
targetAudience: result.target_audience || prev.aiParsedContent.targetAudience,
contentRequirements: result.content_requirements || prev.aiParsedContent.contentRequirements,
},
// 如果 AI 解析出了卖点且当前没有卖点,则自动填充
sellingPoints: result.selling_points?.length
? result.selling_points.map((sp, i) => ({
id: `sp-ai-${i}`,
content: sp.content,
priority: ((sp as any).priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
}))
: prev.sellingPoints,
// 如果 AI 解析出了违禁词且当前没有违禁词,则自动填充
blacklistWords: result.blacklist_words?.length
? result.blacklist_words.map((bw, i) => ({
id: `bw-ai-${i}`,
word: bw.word,
reason: bw.reason,
}))
: prev.blacklistWords,
}))
// AI 解析成功后自动保存到后端
if (!USE_MOCK) {
try {
await api.updateBriefByAgency(projectId, {
brand_tone: [result.product_name, result.target_audience].filter(Boolean).join('\n'),
other_requirements: result.content_requirements || undefined,
selling_points: result.selling_points?.length
? result.selling_points.map(sp => ({ content: sp.content, priority: (sp as any).priority || (sp.required ? 'core' : 'recommended') })) as any
: undefined,
blacklist_words: result.blacklist_words?.length
? result.blacklist_words.map(bw => ({ word: bw.word, reason: bw.reason }))
: undefined,
})
} catch (e) {
console.warn('保存 AI 解析结果失败:', e)
}
}
toast.success('AI 解析完成!')
} catch (err: any) {
const msg = err?.message || 'AI 解析失败'
toast.error(msg)
} finally {
setIsAIParsing(false)
}
}
// 保存配置
@@ -474,32 +642,24 @@ export default function BriefConfigPage() {
if (!USE_MOCK) {
try {
const payload = {
// 代理商通过专用 PATCH 端点保存
await api.updateBriefByAgency(projectId, {
selling_points: agencyConfig.sellingPoints.map(sp => ({
content: sp.content,
required: sp.required,
})),
priority: sp.priority,
})) as any,
blacklist_words: agencyConfig.blacklistWords.map(bw => ({
word: bw.word,
reason: bw.reason,
})),
competitors: brandBrief.brandRules.competitors,
brand_tone: agencyConfig.aiParsedContent.productName,
other_requirements: brandBrief.brandRules.restrictions,
agency_attachments: agencyConfig.agencyFiles.map(f => ({
id: f.id,
name: f.name,
url: f.url || '',
size: f.size,
})),
}
// 尝试更新,如果 Brief 不存在则创建
try {
await api.updateBrief(projectId, payload)
} catch {
await api.createBrief(projectId, payload)
}
min_selling_points: minSellingPoints,
})
setIsSaving(false)
toast.success('配置已保存!')
@@ -523,7 +683,7 @@ export default function BriefConfigPage() {
if (!newSellingPoint.trim()) return
setAgencyConfig(prev => ({
...prev,
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }]
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, priority: 'recommended' as const }]
}))
setNewSellingPoint('')
}
@@ -535,12 +695,15 @@ export default function BriefConfigPage() {
}))
}
const toggleRequired = (id: string) => {
const cyclePriority = (id: string) => {
const order: Array<'core' | 'recommended' | 'reference'> = ['core', 'recommended', 'reference']
setAgencyConfig(prev => ({
...prev,
sellingPoints: prev.sellingPoints.map(sp =>
sp.id === id ? { ...sp, required: !sp.required } : sp
)
sellingPoints: prev.sellingPoints.map(sp => {
if (sp.id !== id) return sp
const idx = order.indexOf(sp.priority)
return { ...sp, priority: order[(idx + 1) % order.length] }
})
}))
}
@@ -561,6 +724,20 @@ export default function BriefConfigPage() {
}))
}
// 自动保存代理商附件到后端(防止刷新丢失)
const autoSaveAgencyFiles = useCallback(async (files: AgencyFile[]) => {
if (USE_MOCK) return
try {
await api.updateBriefByAgency(projectId, {
agency_attachments: files.map(f => ({
id: f.id, name: f.name, url: f.url || '', size: f.size,
})),
})
} catch (e) {
console.warn('自动保存代理商附件失败:', e)
}
}, [projectId])
// 上传单个代理商文件
const uploadSingleAgencyFile = async (file: File, fileId: string) => {
if (USE_MOCK) {
@@ -589,7 +766,12 @@ export default function BriefConfigPage() {
id: fileId, name: file.name, size: formatFileSize(file.size),
uploadedAt: new Date().toISOString().split('T')[0], url: result.url,
}
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
setAgencyConfig(prev => {
const updated = [...prev.agencyFiles, newFile]
// 文件上传成功后自动保存到后端
autoSaveAgencyFiles(updated)
return { ...prev, agencyFiles: updated }
})
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
@@ -639,10 +821,12 @@ export default function BriefConfigPage() {
}
const removeAgencyFile = (id: string) => {
setAgencyConfig(prev => ({
...prev,
agencyFiles: prev.agencyFiles.filter(f => f.id !== id)
}))
setAgencyConfig(prev => {
const updated = prev.agencyFiles.filter(f => f.id !== id)
// 删除后也自动保存
autoSaveAgencyFiles(updated)
return { ...prev, agencyFiles: updated }
})
}
const [previewAgencyUrl, setPreviewAgencyUrl] = useState<string | null>(null)
@@ -653,8 +837,8 @@ export default function BriefConfigPage() {
if (!USE_MOCK && file.url) {
setPreviewAgencyLoading(true)
try {
const signedUrl = await api.getSignedUrl(file.url)
setPreviewAgencyUrl(signedUrl)
const blobUrl = await api.getPreviewUrl(file.url)
setPreviewAgencyUrl(blobUrl)
} catch {
toast.error('获取预览链接失败')
} finally {
@@ -669,17 +853,7 @@ export default function BriefConfigPage() {
return
}
try {
const signedUrl = await api.getSignedUrl(file.url)
const resp = await fetch(signedUrl)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
await api.downloadFile(file.url, file.name)
} catch {
toast.error('下载失败')
}
@@ -855,6 +1029,126 @@ export default function BriefConfigPage() {
</Card>
</div>
{/* ===== 任务管理区块 ===== */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users size={18} className="text-accent-green" />
<span className="text-sm font-normal text-text-secondary">
{projectTasks.length}
</span>
</CardTitle>
<Button size="sm" onClick={() => setShowCreatorModal(true)}>
<UserPlus size={14} />
</Button>
</CardHeader>
<CardContent>
{projectTasks.length > 0 ? (
<div className="space-y-3">
{projectTasks.map((task) => {
const uiState = mapTaskToUI(task)
return (
<div key={task.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-accent-indigo/15 flex items-center justify-center">
<span className="text-sm font-bold text-accent-indigo">
{task.creator.name.charAt(0)}
</span>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary text-sm">{task.name}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-text-secondary">
<span>: {task.creator.name}</span>
<span>: {task.created_at.split('T')[0]}</span>
</div>
</div>
</div>
<span className={`px-2.5 py-1 text-xs font-medium rounded-lg ${
uiState.statusLabel === '已完成' ? 'bg-accent-green/15 text-accent-green' :
uiState.statusLabel === '已驳回' ? 'bg-accent-coral/15 text-accent-coral' :
uiState.statusLabel === '待上传' ? 'bg-yellow-500/15 text-yellow-400' :
'bg-accent-indigo/15 text-accent-indigo'
}`}>
{uiState.statusLabel}
</span>
</div>
)
})}
</div>
) : (
<div className="py-12 text-center">
<Users size={40} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
)}
</CardContent>
</Card>
{/* 达人选择弹窗 */}
<Modal
isOpen={showCreatorModal}
onClose={() => setShowCreatorModal(false)}
title="选择达人"
size="md"
>
<div className="space-y-2">
<p className="text-sm text-text-secondary mb-4">
</p>
{availableCreators.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{availableCreators.map((creator) => {
const taskCount = projectTasks.filter(t => t.creator.id === creator.id).length
return (
<button
key={creator.id}
type="button"
onClick={() => handleCreateTask(creator.id)}
disabled={creatingTask}
className="w-full flex items-center justify-between p-4 bg-bg-elevated rounded-lg hover:bg-bg-elevated/80 transition-colors disabled:opacity-50"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent-indigo/15 flex items-center justify-center">
<span className="text-sm font-bold text-accent-indigo">
{creator.name.charAt(0)}
</span>
</div>
<div className="text-left">
<p className="font-medium text-text-primary text-sm">{creator.name}</p>
<p className="text-xs text-text-tertiary">
{[creator.douyin_account && '抖音', creator.xiaohongshu_account && '小红书', creator.bilibili_account && 'B站'].filter(Boolean).join(' · ') || '暂无平台账号'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{taskCount > 0 && (
<span className="text-xs text-text-tertiary"> {taskCount} </span>
)}
{creatingTask ? (
<Loader2 size={16} className="animate-spin text-accent-indigo" />
) : (
<Plus size={16} className="text-text-tertiary" />
)}
</div>
</button>
)
})}
</div>
) : (
<div className="py-8 text-center">
<Users size={40} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
)}
</div>
</Modal>
{/* ===== 第二部分:代理商配置(可编辑)===== */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
@@ -1035,12 +1329,14 @@ export default function BriefConfigPage() {
<div key={sp.id} className="flex items-center gap-3 p-3 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => toggleRequired(sp.id)}
onClick={() => cyclePriority(sp.id)}
className={`px-2 py-1 text-xs rounded ${
sp.required ? 'bg-accent-coral/20 text-accent-coral' : 'bg-bg-page text-text-tertiary'
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.required ? '必选' : '可选'}
{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}
</button>
<span className="flex-1 text-text-primary">{sp.content}</span>
<button
@@ -1066,6 +1362,48 @@ export default function BriefConfigPage() {
</Button>
</div>
{/* 最少卖点数配置 */}
<div className="p-3 bg-bg-elevated rounded-lg border border-border-subtle">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary"></p>
<p className="text-xs text-text-tertiary mt-0.5">
AI +
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setMinSellingPoints(prev => prev === null ? Math.max(1, agencyConfig.sellingPoints.filter(sp => sp.priority !== 'reference').length) : prev > 1 ? prev - 1 : prev)}
className="w-8 h-8 rounded-lg bg-bg-page border border-border-subtle flex items-center justify-center hover:bg-bg-card transition-colors text-text-secondary"
>
-
</button>
<span className="w-10 text-center text-sm font-medium text-text-primary">
{minSellingPoints ?? '-'}
</span>
<button
type="button"
onClick={() => {
const max = agencyConfig.sellingPoints.filter(sp => sp.priority !== 'reference').length
setMinSellingPoints(prev => prev === null ? 1 : prev < max ? prev + 1 : prev)
}}
className="w-8 h-8 rounded-lg bg-bg-page border border-border-subtle flex items-center justify-center hover:bg-bg-card transition-colors text-text-secondary"
>
+
</button>
{minSellingPoints !== null && (
<button
type="button"
onClick={() => setMinSellingPoints(null)}
className="text-xs text-text-tertiary hover:text-text-secondary ml-1"
>
</button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
@@ -1075,7 +1413,7 @@ export default function BriefConfigPage() {
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-amber" />
{rules.name}
{platformRuleName || platform?.name || ''}
</span>
<Button variant="secondary" size="sm" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={14} />
@@ -1084,7 +1422,7 @@ export default function BriefConfigPage() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rules.rules.map((rule, index) => (
{dynamicPlatformRules.map((rule, index) => (
<div key={index}>
<p className="text-sm font-medium text-text-primary mb-2">{rule.category}</p>
<div className="flex flex-wrap gap-2">
@@ -1172,7 +1510,8 @@ export default function BriefConfigPage() {
<div>
<p className="text-sm text-accent-green font-medium"></p>
<ul className="text-xs text-accent-green/80 mt-1 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> AI </li>
<li> </li>
</ul>
@@ -1225,7 +1564,7 @@ export default function BriefConfigPage() {
{/* 文件预览弹窗(品牌方) */}
<Modal
isOpen={!!previewFile}
onClose={() => { setPreviewFile(null); setPreviewUrl(null) }}
onClose={() => { setPreviewFile(null); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null) } }}
title={previewFile?.name || '文件预览'}
size="lg"
>
@@ -1262,7 +1601,7 @@ export default function BriefConfigPage() {
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setPreviewFile(null); setPreviewUrl(null) }}>
<Button variant="secondary" onClick={() => { setPreviewFile(null); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null) } }}>
</Button>
{previewFile && (
@@ -1337,7 +1676,7 @@ export default function BriefConfigPage() {
{/* 代理商文档预览弹窗 */}
<Modal
isOpen={!!previewAgencyFile}
onClose={() => { setPreviewAgencyFile(null); setPreviewAgencyUrl(null) }}
onClose={() => { setPreviewAgencyFile(null); if (previewAgencyUrl) { URL.revokeObjectURL(previewAgencyUrl); setPreviewAgencyUrl(null) } }}
title={previewAgencyFile?.name || '文件预览'}
size="lg"
>
@@ -1374,7 +1713,7 @@ export default function BriefConfigPage() {
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setPreviewAgencyFile(null); setPreviewAgencyUrl(null) }}>
<Button variant="secondary" onClick={() => { setPreviewAgencyFile(null); if (previewAgencyUrl) { URL.revokeObjectURL(previewAgencyUrl); setPreviewAgencyUrl(null) } }}>
</Button>
{previewAgencyFile && (
+2 -2
View File
@@ -204,8 +204,8 @@ export default function AgencyBriefsPage() {
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Brief </h1>
<p className="text-sm text-text-secondary mt-1"> Brief</p>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"> Brief</p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium">
+67 -44
View File
@@ -477,15 +477,36 @@ export default function AgencyCreatorsPage() {
setOpenMenuId(null)
}
// 确认分配项目
const handleConfirmAssign = () => {
// 确认分配项目(创建任务)
const handleConfirmAssign = async () => {
const projectList = USE_MOCK ? mockProjects : projects
if (assignModal.creator && selectedProject) {
const project = projectList.find(p => p.id === selectedProject)
if (!assignModal.creator || !selectedProject) return
const project = projectList.find(p => p.id === selectedProject)
if (USE_MOCK) {
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}`)
setAssignModal({ open: false, creator: null })
setSelectedProject('')
return
}
setSubmitting(true)
try {
await api.createTask({
project_id: selectedProject,
creator_id: assignModal.creator.creatorId,
})
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}`)
setAssignModal({ open: false, creator: null })
setSelectedProject('')
await fetchData() // 刷新列表
} catch (err) {
const message = err instanceof Error ? err.message : '分配失败'
toast.error(message)
} finally {
setSubmitting(false)
}
setAssignModal({ open: false, creator: null })
setSelectedProject('')
}
// 骨架屏
@@ -761,43 +782,45 @@ export default function AgencyCreatorsPage() {
</td>
<td className="px-6 py-4 text-sm text-text-tertiary">{creator.joinedAt}</td>
<td className="px-6 py-4">
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleOpenAssign(creator)}
className="px-3 py-1.5 text-xs font-medium text-accent-indigo bg-accent-indigo/10 hover:bg-accent-indigo/20 rounded-lg transition-colors flex items-center gap-1.5"
>
<MoreVertical size={16} />
</Button>
{/* 下拉菜单 */}
{openMenuId === creator.id && (
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
<button
type="button"
onClick={() => handleOpenRemark(creator)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<MessageSquareText size={14} className="text-text-secondary" />
{creator.remark ? '编辑备注' : '添加备注'}
</button>
<button
type="button"
onClick={() => handleOpenAssign(creator)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(creator)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
<FolderPlus size={13} />
</button>
<button
type="button"
onClick={() => handleOpenDelete(creator)}
className="p-1.5 text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 rounded-lg transition-colors"
title="移除达人"
>
<Trash2 size={14} />
</button>
<div className="relative">
<button
type="button"
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
className="p-1.5 text-text-tertiary hover:text-text-primary hover:bg-bg-elevated rounded-lg transition-colors"
title="更多操作"
>
<MoreVertical size={14} />
</button>
{openMenuId === creator.id && (
<div className="absolute right-0 top-full mt-1 w-36 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
<button
type="button"
onClick={() => handleOpenRemark(creator)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<MessageSquareText size={14} className="text-text-secondary" />
{creator.remark ? '编辑备注' : '添加备注'}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
@@ -1042,8 +1065,8 @@ export default function AgencyCreatorsPage() {
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, creator: null }); setSelectedProject(''); }}>
</Button>
<Button onClick={handleConfirmAssign} disabled={!selectedProject}>
<FolderPlus size={16} />
<Button onClick={handleConfirmAssign} disabled={!selectedProject || submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
</Button>
</div>
+29 -12
View File
@@ -96,8 +96,12 @@ function getTaskUrgencyLevel(task: TaskResponse): string {
}
function getTaskUrgencyTitle(task: TaskResponse): string {
const type = task.stage.includes('video') ? '视频' : '脚本'
return `${task.creator.name}${type} - ${task.name}`
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 {
@@ -182,13 +186,19 @@ export default function AgencyDashboard() {
if (loading || !stats) return <DashboardSkeleton />
// Build urgent todos from pending tasks (top 3)
const urgentTodos = pendingTasks.slice(0, 3).map(task => ({
id: task.id,
title: getTaskUrgencyTitle(task),
description: task.project.name,
time: getTaskTimeAgo(task.updated_at),
level: getTaskUrgencyLevel(task),
}))
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">
@@ -316,6 +326,9 @@ export default function AgencyDashboard() {
{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}
@@ -356,6 +369,7 @@ export default function AgencyDashboard() {
<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>
@@ -369,7 +383,9 @@ export default function AgencyDashboard() {
<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 className="font-medium text-text-primary">{task.name}</div>
<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">
@@ -385,7 +401,8 @@ export default function AgencyDashboard() {
</span>
</td>
<td className="py-4 text-text-secondary">{task.creator.name}</td>
<td className="py-4 text-text-secondary">{task.project.brand_name || task.project.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 ${
@@ -409,7 +426,7 @@ export default function AgencyDashboard() {
)
}) : (
<tr>
<td colSpan={7} className="py-8 text-center text-text-tertiary"></td>
<td colSpan={8} className="py-8 text-center text-text-tertiary"></td>
</tr>
)}
</tbody>
+38 -555
View File
@@ -1,577 +1,60 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import { Loader2 } from 'lucide-react'
// ==================== Mock 数据 ====================
const mockTask: 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,
script_ai_result: {
score: 85,
violations: [
{
type: '违禁词',
content: '效果最好',
severity: 'high',
suggestion: '建议替换为"效果显著"',
timestamp: 15.5,
source: 'speech',
},
{
type: '竞品露出',
content: '疑似竞品Logo',
severity: 'high',
suggestion: '需人工确认是否为竞品露出',
timestamp: 42.0,
source: 'visual',
},
],
soft_warnings: [
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
],
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
},
video_ai_score: 85,
video_ai_result: {
score: 85,
violations: [
{
type: '违禁词',
content: '效果最好',
severity: 'high',
suggestion: '建议替换为"效果显著"',
timestamp: 15.5,
source: 'speech',
},
{
type: '竞品露出',
content: '疑似竞品Logo',
severity: 'high',
suggestion: '需人工确认是否为竞品露出',
timestamp: 42.0,
source: 'visual',
},
],
soft_warnings: [
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
],
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
},
appeal_count: 0,
is_appeal: false,
created_at: '2026-02-03T10:30:00Z',
updated_at: '2026-02-03T10:35:00Z',
}
// ==================== 工具函数 ====================
function getReviewStepStatus(task: TaskResponse): string {
if (task.stage.includes('agency_review')) return 'agent_reviewing'
if (task.stage.includes('brand_review')) return 'brand_reviewing'
if (task.stage === 'completed') return 'completed'
return 'agent_reviewing'
}
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// ==================== 子组件 ====================
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
const steps = getAgencyReviewSteps(taskStatus)
const currentStep = steps.find(s => s.status === 'current')
return (
<Card className="mb-6">
<CardContent className="py-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-sm text-accent-indigo font-medium">
{currentStep?.label || '代理商审核'}
</span>
</div>
<ReviewSteps steps={steps} />
</CardContent>
</Card>
)
}
function RiskLevelTag({ level }: { level: string }) {
if (level === 'high') return <ErrorTag></ErrorTag>
if (level === 'medium') return <WarningTag></WarningTag>
return <SuccessTag></SuccessTag>
}
function ReviewSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="space-y-2">
<div className="h-6 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-64 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-16 bg-bg-elevated rounded-xl" />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-3 space-y-4">
<div className="h-64 bg-bg-elevated rounded-xl" />
<div className="h-20 bg-bg-elevated rounded-xl" />
</div>
<div className="lg:col-span-2 space-y-4">
<div className="h-48 bg-bg-elevated rounded-xl" />
<div className="h-32 bg-bg-elevated rounded-xl" />
</div>
</div>
</div>
)
}
// ==================== 主页面 ====================
export default function ReviewPage() {
/**
* Redirect page: detects task type (script/video) and redirects
* to the appropriate review detail page.
*/
export default function ReviewRedirectPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
const { subscribe } = useSSE()
const [error, setError] = useState('')
const [task, setTask] = useState<TaskResponse | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [showForcePassModal, setShowForcePassModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [forcePassReason, setForcePassReason] = useState('')
const [saveAsException, setSaveAsException] = useState(false)
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const loadTask = useCallback(async () => {
useEffect(() => {
if (USE_MOCK) {
setTask(mockTask)
setLoading(false)
router.replace(`/agency/review/script/${taskId}`)
return
}
try {
const data = await api.getTask(taskId)
setTask(data)
} catch (err) {
console.error('Failed to load task:', err)
toast.error('加载任务失败')
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadTask()
}, [loadTask])
useEffect(() => {
const unsub1 = subscribe('task_updated', (data: any) => {
if (data?.task_id === taskId) loadTask()
})
const unsub2 = subscribe('review_completed', (data: any) => {
if (data?.task_id === taskId) loadTask()
})
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
if (loading || !task) return <ReviewSkeleton />
// Determine if this is script or video review
const isVideoReview = task.stage.includes('video')
const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result
const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score
const violations = aiResult?.violations || []
const softWarnings = aiResult?.soft_warnings || []
const aiSummary = aiResult?.summary || '暂无 AI 分析总结'
const handleApprove = async () => {
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'pass' })
} else {
await api.reviewScript(taskId, { action: 'pass' })
}
async function redirect() {
try {
const task = await api.getTask(taskId)
const isVideo = task.stage.includes('video')
const path = isVideo
? `/agency/review/video/${taskId}`
: `/agency/review/script/${taskId}`
router.replace(path)
} catch {
setError('加载任务失败,请返回重试')
}
toast.success('审核已通过')
setShowApproveModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to approve:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
redirect()
}, [taskId, router])
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
<p className="text-text-secondary">{error}</p>
<button
type="button"
onClick={() => router.back()}
className="text-accent-indigo hover:underline"
>
</button>
</div>
)
}
const handleReject = async () => {
if (!rejectReason.trim()) {
toast.error('请填写驳回原因')
return
}
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
} else {
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
}
}
toast.success('已驳回')
setShowRejectModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to reject:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
}
const handleForcePass = async () => {
if (!forcePassReason.trim()) {
toast.error('请填写强制通过原因')
return
}
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason })
} else {
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
}
}
toast.success('已强制通过')
setShowForcePassModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to force pass:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
}
// 时间线标记
const timelineMarkers = [
...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })),
].sort((a, b) => a.time - b.time)
const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10))
return (
<div className="space-y-4">
{/* 顶部导航 */}
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.name}</h1>
<p className="text-sm text-text-secondary">
{task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'}
</p>
</div>
{task.is_appeal && (
<span className="px-3 py-1 bg-accent-amber/20 text-accent-amber rounded-full text-sm font-medium">
</span>
)}
</div>
{/* 申诉理由 */}
{task.is_appeal && task.appeal_reason && (
<Card className="border-accent-amber/30 bg-accent-amber/5">
<CardContent className="py-3">
<p className="text-sm text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</CardContent>
</Card>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={getReviewStepStatus(task)} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧:视频/脚本播放器 (3/5) */}
<div className="lg:col-span-3 space-y-4">
<Card>
<CardContent className="p-0">
{isVideoReview ? (
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
<button
type="button"
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
</button>
</div>
) : (
<div className="aspect-[4/3] bg-bg-elevated rounded-t-lg flex items-center justify-center">
<div className="text-center">
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1">{task.script_file_name || '脚本文件'}</p>
</div>
</div>
)}
{/* 智能进度条(仅视频且有时间标记时显示) */}
{isVideoReview && timelineMarkers.length > 0 && (
<div className="p-4 border-t border-border-subtle">
<div className="text-sm font-medium text-text-primary mb-3"></div>
<div className="relative h-3 bg-bg-elevated rounded-full">
{timelineMarkers.map((marker, idx) => (
<button
key={idx}
type="button"
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
marker.type === 'hard' ? 'bg-accent-coral' : 'bg-orange-500'
}`}
style={{ left: `${(marker.time / maxTime) * 100}%` }}
title={`${formatTimestamp(marker.time)} - 硬性问题`}
/>
))}
</div>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span>0:00</span>
<span>{formatTimestamp(maxTime)}</span>
</div>
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-coral rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-orange-500 rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-green rounded-full" />
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* AI 分析总结 */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-text-primary">AI </span>
{aiScore != null && (
<span className={`text-xl font-bold ${aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}>
{aiScore}
</span>
)}
</div>
<p className="text-text-secondary text-sm">{aiSummary}</p>
</CardContent>
</Card>
</div>
{/* 右侧:AI 检查单 (2/5) */}
<div className="lg:col-span-2 space-y-4">
{/* 硬性合规 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-red-500" />
({violations.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{violations.length > 0 ? violations.map((v, idx) => {
const key = `v-${idx}`
return (
<div key={key} className={`p-3 rounded-lg border ${checkedViolations[key] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
<div className="flex items-start gap-2">
<input
type="checkbox"
checked={checkedViolations[key] || false}
onChange={() => setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))}
className="mt-1 accent-accent-indigo"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<ErrorTag>{v.type}</ErrorTag>
{v.timestamp != null && (
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
)}
</div>
<p className="text-sm font-medium text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
</div>
</div>
)
}) : (
<div className="text-center py-4 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
{/* 舆情雷达 */}
{softWarnings.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Radio size={16} className="text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{softWarnings.map((w, 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>{w.type}</WarningTag>
</div>
<p className="text-sm text-orange-400">{w.content}</p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
{/* 底部决策栏 */}
<Card className="sticky bottom-4 shadow-lg">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="text-sm text-text-secondary">
{Object.values(checkedViolations).filter(Boolean).length}/{violations.length}
</div>
<div className="flex gap-3">
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
</Button>
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
</Button>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 通过确认弹窗 */}
<ConfirmModal
isOpen={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={handleApprove}
title="确认通过"
message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`}
confirmText="确认通过"
/>
{/* 驳回弹窗 */}
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
<div className="space-y-4">
<p className="text-text-secondary text-sm"></p>
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-sm font-medium text-text-primary mb-2">
({Object.values(checkedViolations).filter(Boolean).length})
</p>
{violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => (
<div key={idx} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
))}
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
<div className="text-sm text-text-tertiary"></div>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="请详细说明驳回原因..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
{/* 强制通过弹窗 */}
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
<div className="space-y-4">
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<p className="text-sm text-yellow-400">
<AlertTriangle size={14} className="inline mr-1" />
</p>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="例如:达人玩的新梗,品牌方认可"
value={forcePassReason}
onChange={(e) => setForcePassReason(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={saveAsException}
onChange={(e) => setSaveAsException(e.target.checked)}
className="rounded accent-accent-indigo"
/>
<span className="text-sm text-text-secondary"></span>
</label>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}></Button>
<Button onClick={handleForcePass} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 size={32} className="animate-spin text-accent-indigo" />
</div>
)
}
+25 -12
View File
@@ -24,6 +24,11 @@ import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse } from '@/types/task'
function platformLabel(id?: string | null): string {
if (!id) return ''
return getPlatformInfo(id)?.name || id
}
// ==================== Mock 数据 ====================
const mockScriptTasks: TaskResponse[] = [
{
@@ -151,7 +156,10 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
<div className="rounded-xl bg-bg-elevated overflow-hidden">
{/* 顶部条 */}
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || task.project.name}</span>
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || ''}</span>
{task.project.platform && (
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
)}
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
@@ -161,12 +169,13 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
</div>
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
<p className="text-xs text-text-secondary mb-3">{task.creator.name}</p>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
@@ -195,7 +204,7 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/${task.id}`}>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
@@ -227,7 +236,10 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || task.project.name}</span>
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || ''}</span>
{task.project.platform && (
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
)}
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
@@ -237,12 +249,13 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
</div>
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
<p className="text-xs text-text-secondary mb-3">{task.creator.name}</p>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
@@ -274,7 +287,7 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/${task.id}`}>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
+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}>
@@ -221,6 +221,7 @@ export default function AgencyVideoReviewPage() {
const [videoError, setVideoError] = useState(false)
const [task, setTask] = useState<VideoTaskViewModel>(mockVideoTask as unknown as VideoTaskViewModel)
const loadTask = useCallback(async () => {
if (USE_MOCK) return
setLoading(true)
+10 -3
View File
@@ -109,9 +109,16 @@ export default function AIConfigPage() {
if (config.available_models && Object.keys(config.available_models).length > 0) {
setAvailableModels(config.available_models)
}
} catch (err) {
console.error('Failed to load AI config:', err)
toast.error('加载 AI 配置失败')
} catch (err: any) {
// 后端 404 返回 "AI 服务未配置" → Axios 拦截器转为 Error(message)
// 这是正常的"尚未配置"状态,不弹错误
const msg = err?.message || ''
if (msg.includes('未配置')) {
setIsConfigured(false)
} else {
console.error('Failed to load AI config:', err)
toast.error('加载 AI 配置失败')
}
} finally {
setLoading(false)
}
+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>
+3 -3
View File
@@ -173,12 +173,12 @@ function mapTaskToUI(task: TaskResponse, type: 'script' | 'video'): UITask {
// 格式化提交时间
const submittedAt = formatDateTime(task.updated_at)
// 平台信息:后端目前不返回平台字段,默认 douyin
const platform = 'douyin'
// 平台信息:从项目获取
const platform = task.project.platform || ''
return {
id: task.id,
title: task.name,
title: `${task.project.name} · ${task.name}`,
fileName,
fileSize: isScript ? '--' : '--',
creatorName: task.creator.name,
+73 -14
View File
@@ -63,7 +63,7 @@ const mockScriptTask = {
},
aiAnalysis: {
violations: [
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium' },
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium', dimension: 'legal' },
],
complianceChecks: [
{ item: '品牌名称正确', passed: true },
@@ -71,6 +71,17 @@ const mockScriptTask = {
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
{ item: '引导语规范', passed: true },
],
dimensions: {
legal: { score: 85, passed: true, issue_count: 1 },
platform: { score: 100, passed: true, issue_count: 0 },
brand_safety: { score: 100, passed: true, issue_count: 0 },
brief_match: { score: 100, passed: true, issue_count: 0 },
},
sellingPointMatches: [
{ content: 'SPF50+ PA++++', priority: 'core' as const, matched: true, evidence: '脚本提及 SPF50+PA++++' },
{ content: '轻薄质地', priority: 'core' as const, matched: true, evidence: '脚本描述质地轻薄不油腻' },
{ content: '延展性好', priority: 'recommended' as const, matched: true, evidence: '脚本演示延展性' },
],
sellingPoints: [
{ point: 'SPF50+ PA++++', covered: true },
{ point: '轻薄质地', covered: true },
@@ -88,6 +99,7 @@ function mapTaskToView(task: TaskResponse) {
content: v.content,
suggestion: v.suggestion,
severity: v.severity,
dimension: v.dimension,
}))
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
@@ -138,6 +150,8 @@ function mapTaskToView(task: TaskResponse) {
aiAnalysis: {
violations,
softWarnings,
dimensions: task.script_ai_result?.dimensions,
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
},
}
@@ -236,6 +250,7 @@ export default function BrandScriptReviewPage() {
},
} : taskData
const handleApprove = async () => {
if (USE_MOCK) {
setShowApproveModal(false)
@@ -492,6 +507,34 @@ export default function BrandScriptReviewPage() {
</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">
@@ -505,6 +548,7 @@ export default function BrandScriptReviewPage() {
<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>
@@ -568,26 +612,41 @@ export default function BrandScriptReviewPage() {
</Card>
)}
{/* 卖点覆盖 */}
{task.aiAnalysis.sellingPoints.length > 0 && (
{/* 卖点匹配 */}
{(task.aiAnalysis.sellingPointMatches?.length > 0 || task.aiAnalysis.sellingPoints.length > 0) && (
<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.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>
))
) : (
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>
))
)}
</CardContent>
</Card>
)}
@@ -316,6 +316,7 @@ export default function BrandVideoReviewPage() {
const [showFilePreview, setShowFilePreview] = useState(false)
const [videoError, setVideoError] = useState(false)
// 加载任务数据
const loadTask = useCallback(async () => {
if (!taskId) return
+6 -2
View File
@@ -582,8 +582,12 @@ export default function RulesPage() {
const handleDeleteWhitelist = async (id: string) => {
setSubmitting(true)
try {
if (USE_MOCK) { setWhitelist(prev => prev.filter(w => w.id !== id)) }
else { setWhitelist(prev => prev.filter(w => w.id !== id)) }
if (USE_MOCK) {
setWhitelist(prev => prev.filter(w => w.id !== id))
} else {
await api.deleteWhitelistItem(id)
await loadWhitelist()
}
toast.success('白名单已删除')
} catch (err) {
toast.error('删除白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
@@ -120,7 +120,7 @@ function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefV
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
id: `sp-${idx}`,
content: sp.content,
required: sp.required,
required: sp.required ?? (sp.priority === 'core'),
}))
// Map blacklist words
@@ -244,10 +244,9 @@ export default function TaskBriefPage() {
return
}
try {
const signedUrl = await api.getSignedUrl(file.url)
window.open(signedUrl, '_blank')
await api.downloadFile(file.url, file.name)
} catch {
toast.error('获取下载链接失败')
toast.error('下载失败')
}
}
+402 -128
View File
@@ -17,7 +17,7 @@ import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
// 前端 UI 使用的任务阶段类型
@@ -57,6 +57,15 @@ type TaskData = {
rejectionReason?: string
submittedAt?: string
scriptContent?: string
aiResult?: {
score: number
dimensions?: ReviewDimensions
sellingPointMatches?: SellingPointMatchResult[]
briefMatchDetail?: BriefMatchDetail
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
}
agencyReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
brandReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
}
type AgencyBriefFile = {
@@ -134,8 +143,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
// 提取 AI 审核结果中的 issues
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
if (aiResult?.violations) {
const dimLabels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
issues = aiResult.violations.map(v => ({
title: v.type,
title: v.dimension ? `[${dimLabels[v.dimension] || v.dimension}] ${v.type}` : v.type,
description: `${v.content}${v.suggestion ? `${v.suggestion}` : ''}`,
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
@@ -144,6 +154,35 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
// AI 审核结果(完整,含维度)
const aiResultData = aiResult ? {
score: aiResult.score,
dimensions: aiResult.dimensions,
sellingPointMatches: aiResult.selling_point_matches,
briefMatchDetail: aiResult.brief_match_detail,
violations: aiResult.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
} : undefined
// 代理商审核反馈
const agencyStatus = phase === 'script' ? task.script_agency_status : task.video_agency_status
const agencyComment = phase === 'script' ? task.script_agency_comment : task.video_agency_comment
const agencyReview = agencyStatus && agencyStatus !== 'pending' ? {
result: (agencyStatus === 'passed' || agencyStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: agencyComment || '',
reviewer: task.agency?.name || '代理商',
time: task.updated_at,
} : undefined
// 品牌方审核反馈
const brandStatus = phase === 'script' ? task.script_brand_status : task.video_brand_status
const brandComment = phase === 'script' ? task.script_brand_comment : task.video_brand_comment
const brandReview = brandStatus && brandStatus !== 'pending' ? {
result: (brandStatus === 'passed' || brandStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
comment: brandComment || '',
reviewer: '品牌方审核员',
time: task.updated_at,
} : undefined
return {
id: task.id,
title: task.name,
@@ -153,6 +192,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
issues: issues.length > 0 ? issues : undefined,
rejectionReason,
submittedAt,
aiResult: aiResultData,
agencyReview,
brandReview,
}
}
@@ -164,11 +206,12 @@ const mockBriefData = {
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', 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 },
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
{ id: 'sp4', content: '适合敏感肌', priority: 'recommended' as const },
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
{ id: 'sp6', content: '产品成分天然', priority: 'reference' as const },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
@@ -278,15 +321,16 @@ function ReviewProgressBar({ task }: { task: TaskData }) {
// Brief 组件
function AgencyBriefSection({ toast, briefData }: {
toast: ReturnType<typeof useToast>
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; required: boolean }[]; blacklistWords: { id: string; word: string; reason: string }[] }
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]; blacklistWords: { id: string; word: string; reason: string }[] }
}) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
return (
<>
@@ -337,21 +381,31 @@ function AgencyBriefSection({ toast, briefData }: {
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
{corePoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
{corePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
{recommendedPoints.length > 0 && (
<div className="p-3 bg-accent-amber/10 rounded-xl border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
{recommendedPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
{referencePoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{referencePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
))}
</div>
@@ -394,42 +448,290 @@ function AgencyBriefSection({ toast, briefData }: {
)
}
function UploadView({ task, toast, briefData }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData }) {
const router = useRouter()
const { id } = useParams()
const isScript = task.phase === 'script'
const uploadPath = isScript ? `/creator/task/${id}/script` : `/creator/task/${id}/video`
function FileUploadSection({ taskId, phase, onUploaded }: { taskId: string; phase: 'script' | 'video'; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const isScript = phase === 'script'
const handleUploadClick = () => {
router.push(uploadPath)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) { setFile(selectedFile); setUploadError(null) }
}
const handleSubmit = async () => {
if (!file) return
setIsUploading(true); setProgress(0); setUploadError(null)
try {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 20) { await new Promise(r => setTimeout(r, 400)); setProgress(i) }
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, isScript ? 'script' : 'video', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
if (isScript) {
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
} else {
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
}
setProgress(100)
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
onUploaded()
}
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadError(msg); toast.error(msg)
} finally { setIsUploading(false) }
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
}
const acceptTypes = isScript ? '.doc,.docx,.pdf,.txt,.xls,.xlsx' : '.mp4,.mov,.avi,.mkv'
const acceptHint = isScript ? '支持 Word、PDF、TXT、Excel 格式' : '支持 MP4/MOV 格式,≤ 100MB'
return (
<div className="bg-bg-card rounded-2xl card-shadow">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
<Upload className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</span>
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo"></span>
</div>
<div className="p-4 space-y-4">
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-xl p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload className="w-8 h-8 mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1">{isScript ? '脚本' : '视频'}</p>
<p className="text-xs text-text-tertiary">{acceptHint}</p>
<input type="file" accept={acceptTypes} onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin text-accent-indigo flex-shrink-0" />
) : uploadError ? (
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0" />
) : (
<CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-accent-indigo flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
{!isUploading && (
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle className="w-4 h-4 text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<>
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 ml-[30px] text-xs text-text-tertiary"> {progress}%</p>
</>
)}
{uploadError && <p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>}
</div>
</div>
)}
<button
type="button"
onClick={handleSubmit}
disabled={!file || isUploading}
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
{isUploading ? <><Loader2 className="w-5 h-5 animate-spin" /> {progress}%</> : <><Upload className="w-5 h-5" />{isScript ? '提交脚本' : '提交视频'}</>}
</button>
</div>
</div>
)
}
function getDimensionLabel(key: string) {
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
return labels[key] || key
}
function AIResultDetailSection({ task }: { task: TaskData }) {
if (!task.aiResult) return null
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
return (
<div className="bg-bg-card rounded-2xl card-shadow">
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
<div className="flex items-center gap-2">
<Bot className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">AI </span>
</div>
<span className={cn('text-xl font-bold', task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral')}>
{task.aiResult.score}
</span>
</div>
<div className="p-4 space-y-4">
{dimensions && (
<div className="grid grid-cols-2 gap-3">
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
const dim = dimensions[key]
if (!dim) return null
return (
<div key={key} className={cn('p-3 rounded-xl border', dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20')}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
{dim.passed ? <CheckCircle className="w-4 h-4 text-accent-green" /> : <XCircle className="w-4 h-4 text-accent-coral" />}
</div>
<span className={cn('text-lg font-bold', dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral')}>{dim.score}</span>
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} )</span>}
</div>
)
})}
</div>
)}
{violations.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-accent-coral" /> ({violations.length})
</h4>
<div className="space-y-2">
{violations.map((v, idx) => (
<div key={idx} className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-accent-coral/15 text-accent-coral">{v.type}</span>
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
</div>
</div>
)}
{/* Brief 匹配度详情 */}
{briefMatchDetail && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-indigo" /> Brief
</h4>
<div className="p-3 bg-bg-elevated rounded-xl space-y-3">
{/* 评分说明 */}
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
{/* 覆盖率进度条 */}
{briefMatchDetail.total_points > 0 && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary"></span>
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} </span>
</div>
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
<div className={cn('h-full rounded-full transition-all', briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral')} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
</div>
</div>
)}
{/* 亮点 */}
{briefMatchDetail.highlights.length > 0 && (
<div>
<p className="text-xs text-accent-green font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.highlights.map((h, i) => (
<div key={i} className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{h}</span>
</div>
))}
</div>
</div>
)}
{/* 问题点 */}
{briefMatchDetail.issues.length > 0 && (
<div>
<p className="text-xs text-accent-coral font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-accent-coral flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{issue}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* 卖点匹配列表 */}
{sellingPointMatches && sellingPointMatches.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{sellingPointMatches.map((sp, idx) => (
<div key={idx} className="flex items-start gap-2 p-2.5 rounded-xl bg-bg-elevated">
{sp.matched ? <CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 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={cn('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>
))}
</div>
</div>
)}
</div>
</div>
)
}
function ReviewFeedbackCard({ review, type }: { review: { result: string; comment: string; reviewer: string; time: string }; type: 'agency' | 'brand' }) {
const isApproved = review.result === 'approved'
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
return (
<div className={cn('bg-bg-card rounded-2xl card-shadow border', isApproved ? 'border-accent-green/30' : 'border-accent-coral/30')}>
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
{isApproved ? <CheckCircle className="w-5 h-5 text-accent-green" /> : <XCircle className="w-5 h-5 text-accent-coral" />}
<span className="text-base font-semibold text-text-primary">{title}</span>
</div>
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-primary">{review.reviewer}</span>
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold', isApproved ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral')}>
{isApproved ? '通过' : '驳回'}
</span>
</div>
{review.comment && <p className="text-sm text-text-secondary">{review.comment}</p>}
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
</div>
</div>
)
}
function UploadView({ task, toast, briefData, onUploaded }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData; onUploaded: () => void }) {
const isScript = task.phase === 'script'
return (
<div className="flex flex-col gap-6 h-full">
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</h3>
<p className="text-sm text-text-tertiary">{isScript ? '支持粘贴文本或上传文档' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo"></span>
</div>
<div
className="flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card min-h-[400px] border-border-subtle hover:border-accent-indigo/50 cursor-pointer"
onClick={handleUploadClick}
>
<div className="w-20 h-20 rounded-full bg-accent-indigo/15 flex items-center justify-center">
<Upload className="w-10 h-10 text-accent-indigo" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold text-text-primary"></p>
<p className="text-sm text-text-tertiary">{isScript ? '支持 .doc、.docx、.txt 格式' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
</div>
<button type="button" onClick={handleUploadClick} className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity">
<Upload className="w-5 h-5" />
{isScript ? '上传脚本文档' : '上传视频文件'}
</button>
</div>
<FileUploadSection taskId={task.id} phase={task.phase} onUploaded={onUploaded} />
</div>
)
}
@@ -489,20 +791,20 @@ function AIReviewingView({ task }: { task: TaskData }) {
)
}
function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => void }) {
function RejectionView({ task, onAppeal, onReupload }: { task: TaskData; onAppeal: () => void; onReupload: () => void }) {
const getTitle = () => {
switch (task.stage) {
case 'ai_result': return 'AI 审核结果'
case 'agency_rejected': return '代理商审核结果'
case 'brand_rejected': return '品牌方审核结果'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_rejected': return '品牌方审核驳回'
default: return '审核结果'
}
}
const getStatusText = () => {
switch (task.stage) {
case 'ai_result': return 'AI 检测到问题'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_rejected': return '品牌方审核驳回'
case 'ai_result': return 'AI 检测到问题,请修改后重新上传'
case 'agency_rejected': return '代理商审核驳回,请根据意见修改'
case 'brand_rejected': return '品牌方审核驳回,请根据意见修改'
default: return '需要修改'
}
}
@@ -510,7 +812,7 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1 flex flex-col">
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<XCircle className="w-6 h-6 text-accent-coral" />
@@ -525,35 +827,19 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
</div>
)}
{task.issues && task.issues.length > 0 && (
<div className="py-4 flex flex-col gap-4 flex-1">
<span className="text-sm font-semibold text-text-primary"> {task.issues.length} </span>
<div className="flex flex-col gap-3">
{task.issues.map((issue, index) => (
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold',
issue.severity === 'error' ? 'bg-accent-coral/15 text-accent-coral' : 'bg-amber-500/15 text-amber-500'
)}>
{issue.severity === 'error' ? '违规' : '建议'}
</span>
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
</div>
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
<div className="flex items-center justify-between pt-4">
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
<MessageCircle className="w-[18px] h-[18px]" />
</button>
<button type="button" className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
<button type="button" onClick={onReupload} className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
<Upload className="w-[18px] h-[18px]" />
</button>
</div>
</div>
{task.stage === 'agency_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
{task.stage === 'brand_rejected' && task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
{task.stage === 'brand_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
</div>
)
}
@@ -567,9 +853,14 @@ function WaitingReviewView({ task }: { task: TaskData }) {
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-text-secondary" />
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Clock className="w-6 h-6 text-accent-indigo" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{title}</span>
<span className="text-sm text-text-secondary">{description}</span>
</div>
</div>
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
@@ -600,28 +891,8 @@ function WaitingReviewView({ task }: { task: TaskData }) {
)}
</div>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Clock className="w-6 h-6 text-accent-indigo" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{title}</span>
<span className="text-sm text-text-secondary">{description}</span>
</div>
</div>
<div className="bg-accent-indigo/10 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-accent-indigo flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-[13px] text-text-secondary">
{isAgency ? '代理商通常会在 1-2 个工作日内完成审核。' : '品牌方终审通常需要 1-3 个工作日。'}
</span>
</div>
</div>
</div>
</div>
{!isAgency && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
</div>
)
}
@@ -631,30 +902,6 @@ function ApprovedView({ task }: { task: TaskData }) {
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-text-secondary" />
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
</div>
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-text-primary">{task.submittedAt || '2026-02-01 10:30'}</span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">AI审核</span><span className="text-sm text-accent-green font-medium"></span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-accent-green font-medium"></span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-accent-green font-medium"></span></div>
</div>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-accent-green" />
<span className="text-lg font-semibold text-text-primary"></span>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-green/15 text-accent-green"></span>
</div>
<p className="text-sm text-text-secondary">
{isVideoPhase ? '恭喜!视频已通过所有审核,可以发布了' : '脚本已通过品牌方终审,请继续上传视频'}
</p>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
@@ -677,6 +924,9 @@ function ApprovedView({ task }: { task: TaskData }) {
</div>
</div>
</div>
{task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
{task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
<AIResultDetailSection task={task} />
{!isVideoPhase && (
<div className="flex justify-center pt-4">
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
@@ -701,6 +951,7 @@ export default function TaskDetailPage() {
const [briefData, setBriefData] = useState(mockBriefData)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showReupload, setShowReupload] = useState(false)
const loadTask = useCallback(async () => {
if (USE_MOCK) {
@@ -728,7 +979,7 @@ export default function TaskDetailPage() {
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
id: `sp-${i}`,
content: sp.content,
required: sp.required,
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
})),
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
id: `bw-${i}`,
@@ -762,6 +1013,14 @@ export default function TaskDetailPage() {
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
// AI 审核中时轮询(SSE 后备方案)
useEffect(() => {
if (!taskData || (taskData.stage !== 'ai_reviewing') || USE_MOCK) return
const interval = setInterval(() => { loadTask() }, 5000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskData?.stage, loadTask])
if (isLoading) {
return (
<ResponsiveLayout role="creator">
@@ -793,12 +1052,27 @@ export default function TaskDetailPage() {
}
const renderContent = () => {
// 驳回状态下选择重新上传时,显示上传界面
if (showReupload && (taskData.stage === 'ai_result' || taskData.stage === 'agency_rejected' || taskData.stage === 'brand_rejected')) {
return (
<div className="flex flex-col gap-6 h-full">
<div className="flex items-center gap-3">
<button type="button" onClick={() => setShowReupload(false)} 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>
{taskData.phase === 'script' && <AgencyBriefSection toast={toast} briefData={briefData} />}
<FileUploadSection taskId={taskData.id} phase={taskData.phase} onUploaded={() => { setShowReupload(false); loadTask() }} />
</div>
)
}
switch (taskData.stage) {
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} />
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} onUploaded={loadTask} />
case 'ai_reviewing': return <AIReviewingView task={taskData} />
case 'ai_result':
case 'agency_rejected':
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} />
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} onReupload={() => setShowReupload(true)} />
case 'agency_reviewing':
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
case 'brand_approved': return <ApprovedView task={taskData} />
+146 -38
View File
@@ -16,9 +16,17 @@ import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
// ========== 工具函数 ==========
function getSellingPointPriority(sp: { priority?: string; required?: boolean }): 'core' | 'recommended' | 'reference' {
if (sp.priority) return sp.priority as 'core' | 'recommended' | 'reference'
if (sp.required === true) return 'core'
if (sp.required === false) return 'recommended'
return 'recommended'
}
// ========== 类型 ==========
type AgencyBriefFile = { id: string; name: string; size: string; uploadedAt: string; description?: string }
@@ -29,8 +37,10 @@ type ScriptTaskUI = {
scriptFile: string | null
aiResult: null | {
score: number
violations: Array<{ type: string; content: string; suggestion: string }>
complianceChecks: Array<{ item: string; passed: boolean; note?: string }>
dimensions?: ReviewDimensions
sellingPointMatches?: SellingPointMatchResult[]
briefMatchDetail?: BriefMatchDetail
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
}
agencyReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
brandReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
@@ -38,7 +48,7 @@ type ScriptTaskUI = {
type BriefUI = {
files: AgencyBriefFile[]
sellingPoints: { id: string; content: string; required: boolean }[]
sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]
blacklistWords: { id: string; word: string; reason: string }[]
}
@@ -64,10 +74,10 @@ function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
const aiResult = task.script_ai_result ? {
score: task.script_ai_result.score,
violations: task.script_ai_result.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion })),
complianceChecks: task.script_ai_result.violations.map(v => ({
item: v.type, passed: v.severity !== 'error' && v.severity !== 'warning', note: v.suggestion,
})),
dimensions: task.script_ai_result.dimensions,
sellingPointMatches: task.script_ai_result.selling_point_matches,
briefMatchDetail: task.script_ai_result.brief_match_detail,
violations: task.script_ai_result.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
} : null
const agencyReview = task.script_agency_status && task.script_agency_status !== 'pending' ? {
@@ -100,7 +110,7 @@ function mapBriefToUI(brief: BriefResponse): BriefUI {
files: (brief.attachments || []).map((a, i) => ({
id: a.id || `att-${i}`, name: a.name, size: a.size || '', uploadedAt: brief.updated_at || '',
})),
sellingPoints: (brief.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, required: sp.required })),
sellingPoints: (brief.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, priority: getSellingPointPriority(sp) })),
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, word: bw.word, reason: bw.reason })),
}
}
@@ -112,9 +122,9 @@ const mockBrief: BriefUI = {
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02' },
],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
@@ -133,8 +143,9 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
return (
<>
@@ -172,18 +183,26 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-green" /></h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
{corePoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{requiredPoints.map((sp) => (
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{corePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded">{sp.content}</span>
))}</div>
</div>
)}
{optionalPoints.length > 0 && (
{recommendedPoints.length > 0 && (
<div className="p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{recommendedPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded">{sp.content}</span>
))}</div>
</div>
)}
{referencePoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{optionalPoints.map((sp) => (
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">{referencePoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded">{sp.content}</span>
))}</div>
</div>
@@ -275,8 +294,8 @@ function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: ()
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> WordPDFTXT </p>
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
<p className="text-xs text-text-tertiary"> WordPDFTXTExcel </p>
<input type="file" accept=".doc,.docx,.pdf,.txt,.xls,.xlsx" onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-lg overflow-hidden">
@@ -355,8 +374,15 @@ function AIReviewingSection() {
)
}
function getDimensionLabel(key: string) {
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
return labels[key] || key
}
function AIResultSection({ task }: { task: ScriptTaskUI }) {
if (!task.aiResult) return null
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
return (
<Card>
<CardHeader>
@@ -366,32 +392,107 @@ function AIResultSection({ task }: { task: ScriptTaskUI }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{task.aiResult.violations.length > 0 && (
{dimensions && (
<div className="grid grid-cols-2 gap-3">
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
const dim = dimensions[key]
if (!dim) return null
return (
<div key={key} className={`p-3 rounded-lg border ${dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20'}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
</div>
<span className={`text-lg font-bold ${dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral'}`}>{dim.score}</span>
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} )</span>}
</div>
)
})}
</div>
)}
{violations.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><AlertTriangle size={14} className="text-orange-500" /> ({task.aiResult.violations.length})</h4>
{task.aiResult.violations.map((v, idx) => (
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><AlertTriangle size={14} className="text-orange-500" /> ({violations.length})</h4>
{violations.map((v, idx) => (
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30 mb-2">
<div className="flex items-center gap-2 mb-1"><WarningTag>{v.type}</WarningTag></div>
<div className="flex items-center gap-2 mb-1">
<WarningTag>{v.type}</WarningTag>
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
</div>
)}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="space-y-2">
{task.aiResult.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>
{check.note && <p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>}
{briefMatchDetail && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-indigo" />Brief </h4>
<div className="p-3 bg-bg-elevated rounded-lg space-y-3">
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
{briefMatchDetail.total_points > 0 && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary"></span>
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} </span>
</div>
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral'}`} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
</div>
</div>
</div>
))}
)}
{briefMatchDetail.highlights.length > 0 && (
<div>
<p className="text-xs text-accent-green font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.highlights.map((h, i) => (
<div key={i} className="flex items-start gap-2">
<CheckCircle size={14} className="text-accent-green flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{h}</span>
</div>
))}
</div>
</div>
)}
{briefMatchDetail.issues.length > 0 && (
<div>
<p className="text-xs text-accent-coral font-medium mb-1"></p>
<div className="space-y-1">
{briefMatchDetail.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-2">
<AlertTriangle size={14} className="text-accent-coral flex-shrink-0 mt-0.5" />
<span className="text-xs text-text-secondary">{issue}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{sellingPointMatches && sellingPointMatches.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-green" /></h4>
<div className="space-y-2">
{sellingPointMatches.map((sp, idx) => (
<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>
))}
</div>
</div>
)}
</CardContent>
</Card>
)
@@ -484,6 +585,13 @@ export default function CreatorScriptPage() {
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
// AI 审核中时轮询(SSE 的后备方案)
useEffect(() => {
if (task.scriptStatus !== 'ai_reviewing' || USE_MOCK) return
const interval = setInterval(() => { loadTask() }, 5000)
return () => clearInterval(interval)
}, [task.scriptStatus, loadTask])
const handleContinueToVideo = () => { router.push(`/creator/task/${params.id}/video`) }
const getStatusDisplay = () => {
@@ -362,6 +362,13 @@ export default function CreatorVideoPage() {
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
// AI 审核中时轮询(SSE 的后备方案)
useEffect(() => {
if (task.videoStatus !== 'ai_reviewing' || USE_MOCK) return
const interval = setInterval(() => { loadTask() }, 5000)
return () => clearInterval(interval)
}, [task.videoStatus, loadTask])
const getStatusDisplay = () => {
const map: Record<string, string> = {
pending_upload: '待上传视频', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',