'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
ArrowLeft,
FileText,
Shield,
Plus,
Trash2,
AlertTriangle,
AlertCircle,
CheckCircle,
Bot,
Users,
Save,
Upload,
ChevronDown,
ChevronUp,
Loader2,
Search,
RotateCcw
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
// 单个文件的上传状态
interface UploadFileItem {
id: string
name: string
size: string
status: 'uploading' | 'success' | 'error'
progress: number
url?: string
error?: string
file?: File
}
// ==================== Mock 数据 ====================
const mockBrief: BriefResponse = {
id: 'bf-001',
project_id: 'proj-001',
project_name: 'XX品牌618推广',
selling_points: [
{ content: '视频时长:60-90秒', required: true },
{ content: '必须展示产品使用过程', required: true },
{ content: '需要口播品牌slogan:"XX品牌,夏日焕新"', required: true },
{ content: '背景音乐需使用品牌指定曲库', required: false },
],
blacklist_words: [
{ word: '最好', reason: '违反广告法' },
{ word: '第一', reason: '违反广告法' },
{ word: '绝对', reason: '夸大宣传' },
{ word: '100%', reason: '夸大宣传' },
],
competitors: ['竞品A', '竞品B', '竞品C'],
brand_tone: '年轻、活力、清新',
min_duration: 60,
max_duration: 90,
other_requirements: '本次618大促营销活动,需要达人围绕夏日护肤、美妆新品进行内容创作。',
attachments: [
{ id: 'att-001', name: '品牌视觉指南.pdf', url: 'https://example.com/brand-guide.pdf' },
{ id: 'att-002', name: '产品资料包.zip', url: 'https://example.com/product-pack.zip' },
],
created_at: '2026-02-01T00:00:00Z',
updated_at: '2026-02-05T00:00:00Z',
}
const mockRules = {
aiReview: {
enabled: true,
strictness: 'medium',
checkItems: [
{ id: 'forbidden_words', name: '违禁词检测', enabled: true },
{ id: 'competitor', name: '竞品提及检测', enabled: true },
{ id: 'brand_tone', name: '品牌调性检测', enabled: true },
{ id: 'duration', name: '视频时长检测', enabled: true },
{ id: 'music', name: '背景音乐检测', enabled: false },
],
},
manualReview: {
scriptRequired: true,
videoRequired: true,
agencyCanApprove: true,
brandFinalReview: true,
},
appealRules: {
maxAppeals: 3,
appealDeadline: 48,
},
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
}
// 严格程度选项
const strictnessOptions = [
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
{ value: 'medium', label: '标准', description: '平衡检测,推荐使用' },
{ value: 'high', label: '严格', description: '严格检测,可能有较多误判' },
]
function ConfigSkeleton() {
return (
{[1, 2, 3, 4].map(i => (
))}
)
}
export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
// 附件上传跟踪
const [uploadingFiles, setUploadingFiles] = useState([])
// Brief state
const [briefExists, setBriefExists] = useState(false)
const [loading, setLoading] = useState(true)
const [projectName, setProjectName] = useState('')
// Brief form fields
const [brandTone, setBrandTone] = useState('')
const [otherRequirements, setOtherRequirements] = useState('')
const [minDuration, setMinDuration] = useState()
const [maxDuration, setMaxDuration] = useState()
const [sellingPoints, setSellingPoints] = useState([])
const [blacklistWords, setBlacklistWords] = useState([])
const [competitors, setCompetitors] = useState([])
const [attachments, setAttachments] = useState([])
// Rules state (local only — no per-project backend API yet)
const [rules, setRules] = useState(mockRules)
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState('brief')
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [conflicts, setConflicts] = useState([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef(null)
const platformOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'bilibili', label: 'B站' },
]
// 点击外部关闭平台选择下拉
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
setShowPlatformSelect(false)
}
}
if (showPlatformSelect) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setConflicts([
{
brief_rule: '卖点包含:100%纯天然成分',
platform_rule: `${platform} 禁止使用:100%`,
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
},
{
brief_rule: 'Brief 最长时长:5秒',
platform_rule: `${platform} 最短要求:7秒`,
suggestion: 'Brief 最长 5s 低于平台最短要求 7s,视频可能不达标',
},
])
setShowConflictModal(true)
setIsCheckingConflicts(false)
return
}
try {
const brandId = user?.brand_id || ''
const briefRules: Record = {
selling_points: sellingPoints.map(sp => sp.content),
min_duration: minDuration,
max_duration: maxDuration,
}
const result = await api.validateRules({
brand_id: brandId,
platform,
brief_rules: briefRules,
})
setConflicts(result.conflicts)
if (result.conflicts.length > 0) {
setShowConflictModal(true)
} else {
toast.success('未发现规则冲突')
}
} catch (err) {
console.error('规则冲突检测失败:', err)
toast.error('规则冲突检测失败')
} finally {
setIsCheckingConflicts(false)
}
}
// Input fields
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
const [newBlacklistReason, setNewBlacklistReason] = useState('')
const [newCompetitor, setNewCompetitor] = useState('')
const populateBrief = (data: BriefResponse) => {
setProjectName(data.project_name || '')
setBrandTone(data.brand_tone || '')
setOtherRequirements(data.other_requirements || '')
setMinDuration(data.min_duration ?? undefined)
setMaxDuration(data.max_duration ?? undefined)
setSellingPoints(data.selling_points || [])
setBlacklistWords(data.blacklist_words || [])
setCompetitors(data.competitors || [])
setAttachments(data.attachments || [])
}
const loadBrief = useCallback(async () => {
if (USE_MOCK) {
populateBrief(mockBrief)
setBriefExists(true)
setLoading(false)
return
}
try {
const data = await api.getBrief(projectId)
populateBrief(data)
setBriefExists(true)
} catch (err: any) {
if (err?.response?.status === 404) {
setBriefExists(false)
} else {
console.error('Failed to load brief:', err)
toast.error('加载Brief失败')
}
} finally {
setLoading(false)
}
}, [projectId, toast])
useEffect(() => {
loadBrief()
}, [loadBrief])
const handleSaveBrief = async () => {
setIsSaving(true)
try {
const briefData: BriefCreateRequest = {
selling_points: sellingPoints,
blacklist_words: blacklistWords,
competitors,
brand_tone: brandTone || undefined,
min_duration: minDuration,
max_duration: maxDuration,
other_requirements: otherRequirements || undefined,
attachments,
}
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else if (briefExists) {
await api.updateBrief(projectId, briefData)
} else {
await api.createBrief(projectId, briefData)
setBriefExists(true)
}
toast.success('Brief配置已保存')
} catch (err) {
console.error('Failed to save brief:', err)
toast.error('保存失败,请重试')
} finally {
setIsSaving(false)
}
}
// Selling points
const addSellingPoint = () => {
if (newSellingPoint.trim()) {
setSellingPoints([...sellingPoints, { content: newSellingPoint.trim(), required: false }])
setNewSellingPoint('')
}
}
const removeSellingPoint = (index: number) => {
setSellingPoints(sellingPoints.filter((_, i) => i !== index))
}
const toggleSellingPointRequired = (index: number) => {
setSellingPoints(sellingPoints.map((sp, i) =>
i === index ? { ...sp, required: !sp.required } : sp
))
}
// Blacklist words
const addBlacklistWord = () => {
if (newBlacklistWord.trim()) {
setBlacklistWords([...blacklistWords, { word: newBlacklistWord.trim(), reason: newBlacklistReason.trim() || '品牌规范' }])
setNewBlacklistWord('')
setNewBlacklistReason('')
}
}
const removeBlacklistWord = (index: number) => {
setBlacklistWords(blacklistWords.filter((_, i) => i !== index))
}
// Competitors
const addCompetitorItem = () => {
if (newCompetitor.trim() && !competitors.includes(newCompetitor.trim())) {
setCompetitors([...competitors, newCompetitor.trim()])
setNewCompetitor('')
}
}
const removeCompetitor = (name: string) => {
setCompetitors(competitors.filter(c => c !== name))
}
// 上传单个附件(独立跟踪进度)
const uploadSingleAttachment = async (file: File, fileId: string) => {
if (USE_MOCK) {
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
await new Promise(r => setTimeout(r, 300))
const att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
return
}
try {
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
}
}
const handleAttachmentUpload = (e: React.ChangeEvent) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadingFiles(prev => [...prev, ...newItems])
newItems.forEach(item => uploadSingleAttachment(item.file!, item.id))
}
const retryAttachmentUpload = (fileId: string) => {
const item = uploadingFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleAttachment(item.file, fileId)
}
const removeUploadingFile = (id: string) => {
setUploadingFiles(prev => prev.filter(f => f.id !== id))
}
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id))
}
// AI check item toggles (local state only)
const toggleAiCheckItem = (itemId: string) => {
setRules({
...rules,
aiReview: {
...rules.aiReview,
checkItems: rules.aiReview.checkItems.map(item =>
item.id === itemId ? { ...item, enabled: !item.enabled } : item
),
},
})
}
const SectionHeader = ({ title, icon: Icon, section }: { title: string; icon: React.ElementType; section: string }) => (
)
if (loading) return
return (
{/* 顶部导航 */}
Brief和规则配置
{projectName || `项目 ${projectId}`}
{showPlatformSelect && (
{platformOptions.map((opt) => (
))}
)}
{/* Brief配置 */}
{activeSection === 'brief' && (
{/* 品牌调性 + 视频时长 */}
{/* 其他要求 */}
{/* 卖点 / 创作要求 */}
{sellingPoints.map((sp, index) => (
{sp.content}
{sp.required && 必选}
))}
{/* 禁止词 */}
{blacklistWords.map((bw, index) => (
{bw.word}
{bw.reason &&
— {bw.reason}}
))}
{/* 竞品品牌 */}
{competitors.map((name) => (
{name}
))}
{/* 参考资料 */}
{/* 文件列表 */}
附件列表
{attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length} 个文件
{uploadingFiles.some(f => f.status === 'uploading') && (
· 上传中
)}
{attachments.length === 0 && uploadingFiles.length === 0 ? (
) : (
{/* 已完成的文件 */}
{attachments.map((att) => (
{att.name}
{att.size && {att.size}}
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
{file.status === 'uploading' && (
)}
{file.status === 'error' && (
)}
{file.name}
{file.status === 'uploading' ? `${file.progress}%` : file.size}
{file.status === 'error' && (
)}
{file.status !== 'uploading' && (
)}
{file.status === 'uploading' && (
)}
{file.status === 'error' && file.error && (
{file.error}
)}
))}
)}
)}
{/* AI审核规则 */}
{activeSection === 'ai' && (
{/* AI审核开关 */}
{rules.aiReview.enabled && (
<>
{/* 严格程度 */}
{strictnessOptions.map((option) => (
))}
{/* 检测项目 */}
{rules.aiReview.checkItems.map((item) => (
{item.name}
))}
>
)}
)}
{/* 人工审核规则 */}
{activeSection === 'manual' && (
脚本需要人工审核
脚本提交后需要代理商/品牌方审核
视频需要人工审核
视频提交后需要代理商/品牌方审核
代理商终审权限
允许代理商直接通过/驳回内容,无需品牌方审核
)}
{/* 申诉规则 */}
{activeSection === 'appeal' && (
)}
{/* 规则冲突检测结果弹窗 */}
setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
{conflicts.length === 0 ? (
) : (
<>
发现 {conflicts.length} 处规则冲突,建议在发布前修改
{conflicts.map((conflict, index) => (
Brief
{conflict.brief_rule}
平台
{conflict.platform_rule}
建议
{conflict.suggestion}
))}
>
)}
)
}