'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' && ( {/* 品牌调性 + 视频时长 */}
setBrandTone(e.target.value)} placeholder="例如:年轻、活力、清新" />
setMinDuration(e.target.value ? parseInt(e.target.value) : undefined)} placeholder="最短" /> ~ setMaxDuration(e.target.value ? parseInt(e.target.value) : undefined)} placeholder="最长" />
{/* 其他要求 */}