'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter, useParams } from 'next/navigation' import { useToast } from '@/components/ui/Toast' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' import { Modal } from '@/components/ui/Modal' import { SuccessTag } from '@/components/ui/Tag' import { ArrowLeft, FileText, Download, Eye, Target, Ban, AlertTriangle, Sparkles, FileDown, CheckCircle, Clock, Building2, Info, Plus, X, Save, Upload, Trash2, File, Loader2, Search, AlertCircle, RotateCcw } 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' // 单个文件上传状态 interface UploadingFileItem { id: string name: string size: string status: 'uploading' | 'error' progress: number error?: string file?: File } import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' import type { ProjectResponse } from '@/types/project' // 文件类型 type BriefFile = { id: string name: string type: 'brief' | 'rule' | 'reference' size: string uploadedAt: string url?: string } // 代理商上传的Brief文档(可编辑) type AgencyFile = { id: string name: string size: string uploadedAt: string description?: string url?: string } // ==================== 视图类型 ==================== interface BrandBriefView { id: string projectName: string brandName: string platform: string files: BriefFile[] brandRules: { restrictions: string competitors: string[] } } // ==================== Mock 数据 ==================== // 模拟品牌方 Brief(只读) const mockBrandBrief: BrandBriefView = { id: 'brief-001', projectName: 'XX品牌618推广', brandName: 'XX护肤品牌', platform: 'douyin', // 品牌方上传的文件列表 files: [ { id: 'f1', name: 'XX品牌618推广Brief.pdf', type: 'brief' as const, size: '2.3MB', uploadedAt: '2026-02-01' }, { id: 'f2', name: '产品卖点说明.docx', type: 'reference' as const, size: '1.2MB', uploadedAt: '2026-02-01' }, { id: 'f3', name: '品牌视觉指南.pdf', type: 'reference' as const, size: '5.8MB', uploadedAt: '2026-02-01' }, ], // 品牌方配置的规则(只读) brandRules: { restrictions: '不可提及竞品,不可使用绝对化用语', competitors: ['安耐晒', '资生堂', '兰蔻'], }, } // 代理商自己的配置(可编辑) const mockAgencyConfig = { status: 'configured', configuredAt: '2026-02-02', // 代理商上传的Brief文档(给达人看的) agencyFiles: [ { id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' }, { id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' }, ] as AgencyFile[], // AI 解析出的内容 aiParsedContent: { productName: 'XX品牌防晒霜', targetAudience: '18-35岁女性', contentRequirements: '需展示产品质地、使用效果,视频时长30-60秒', }, // 代理商配置的卖点(可编辑) 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 }, ], // 代理商配置的违禁词(可编辑) blacklistWords: [ { id: 'bw1', word: '最好', reason: '绝对化用语' }, { id: 'bw2', word: '第一', reason: '绝对化用语' }, { id: 'bw3', word: '神器', reason: '夸大宣传' }, { id: 'bw4', word: '完美', reason: '绝对化用语' }, ], } // 平台规则 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%', '纯天然', '无副作用'] }, ], }, } // ==================== 工具函数 ==================== 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' } // ==================== 组件 ==================== function BriefDetailSkeleton() { return (
) } export default function BriefConfigPage() { const router = useRouter() const params = useParams() const toast = useToast() const { user } = useAuth() const projectId = params.id as string const agencyFileInputRef = useRef(null) // 上传中的文件跟踪 const [uploadingFiles, setUploadingFiles] = useState([]) // 加载状态 const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) // 品牌方 Brief(只读) const [brandBrief, setBrandBrief] = useState(mockBrandBrief) // 代理商配置(可编辑) const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig) const [newSellingPoint, setNewSellingPoint] = useState('') const [newBlacklistWord, setNewBlacklistWord] = useState('') // 弹窗状态 const [showFilesModal, setShowFilesModal] = useState(false) const [showAgencyFilesModal, setShowAgencyFilesModal] = useState(false) const [previewFile, setPreviewFile] = useState(null) const [previewAgencyFile, setPreviewAgencyFile] = useState(null) const [isExporting, setIsExporting] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isAIParsing, setIsAIParsing] = useState(false) const isUploading = uploadingFiles.some(f => f.status === 'uploading') // 规则冲突检测 const [isCheckingConflicts, setIsCheckingConflicts] = useState(false) const [showConflictModal, setShowConflictModal] = useState(false) const [ruleConflicts, setRuleConflicts] = useState([]) const [showPlatformSelect, setShowPlatformSelect] = useState(false) const platformDropdownRef = useRef(null) const platformSelectOptions = [ { 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)) setRuleConflicts([ { 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 { // 代理商角色可能没有 brand_id,从 brandBrief 取关联品牌的 ID const brandId = user?.brand_id || brandBrief.id || '' const briefRules: Record = { selling_points: agencyConfig.sellingPoints.map(sp => sp.content), } const result = await api.validateRules({ brand_id: brandId, platform, brief_rules: briefRules, }) setRuleConflicts(result.conflicts) if (result.conflicts.length > 0) { setShowConflictModal(true) } else { toast.success('未发现规则冲突') } } catch (err) { console.error('规则冲突检测失败:', err) toast.error('规则冲突检测失败') } finally { setIsCheckingConflicts(false) } } // 加载数据 const loadData = useCallback(async () => { if (USE_MOCK) { // Mock 模式使用默认数据 setLoading(false) return } try { // 1. 获取项目信息 const project = await api.getProject(projectId) // 2. 获取 Brief let brief: BriefResponse | null = null try { brief = await api.getBrief(projectId) } catch { // Brief 不存在,保持空状态 } // 映射到品牌方 Brief 视图 const briefFiles: BriefFile[] = brief?.attachments?.map((att, i) => ({ id: att.id || `att-${i}`, name: att.name, type: 'brief' as const, size: att.size || '未知', uploadedAt: brief!.created_at.split('T')[0], url: att.url, })) || [] if (brief?.file_name) { briefFiles.unshift({ id: 'main-file', name: brief.file_name, type: 'brief' as const, size: '未知', uploadedAt: brief.created_at.split('T')[0], url: brief.file_url || undefined, }) } setBrandBrief({ id: brief?.id || `no-brief-${projectId}`, projectName: project.name, brandName: project.brand_name || '未知品牌', platform: project.platform || 'douyin', files: briefFiles, brandRules: { restrictions: brief?.other_requirements || '暂无限制条件', competitors: brief?.competitors || [], }, }) // 映射到代理商配置视图 const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone) setAgencyConfig({ status: hasBrief ? 'configured' : 'pending', configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '', agencyFiles: (brief?.agency_attachments || []).map((att: any) => ({ id: att.id || `af-${Math.random().toString(36).slice(2, 6)}`, name: att.name, size: att.size || '未知', 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 || '待解析'), }, sellingPoints: (brief?.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, required: sp.required, })), blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, word: bw.word, reason: bw.reason, })), }) } catch (err) { console.error('加载 Brief 详情失败:', err) toast.error('加载 Brief 详情失败') } finally { setLoading(false) } }, [projectId, toast]) useEffect(() => { loadData() }, [loadData]) const platform = getPlatformInfo(brandBrief.platform) const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin // 下载文件 const handleDownload = async (file: BriefFile) => { if (USE_MOCK || !file.url) { toast.info(`下载文件: ${file.name}`) return } try { const signedUrl = await api.getSignedUrl(file.url, 3600, true) window.open(signedUrl, '_blank') } catch { toast.error('获取下载链接失败') } } // 预览文件 const [previewUrl, setPreviewUrl] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const handlePreview = async (file: BriefFile) => { setPreviewFile(file) setPreviewUrl(null) if (!USE_MOCK && file.url) { setPreviewLoading(true) try { const signedUrl = await api.getSignedUrl(file.url) setPreviewUrl(signedUrl) } catch { toast.error('获取预览链接失败') } finally { setPreviewLoading(false) } } } // 导出平台规则文档 const handleExportRules = async () => { setIsExporting(true) await new Promise(resolve => setTimeout(resolve, 1500)) setIsExporting(false) toast.success('平台规则文档已导出!') } // AI 解析 const handleAIParse = async () => { setIsAIParsing(true) await new Promise(resolve => setTimeout(resolve, 2000)) setIsAIParsing(false) toast.success('AI 解析完成!') } // 保存配置 const handleSave = async () => { setIsSaving(true) if (!USE_MOCK) { try { const payload = { selling_points: agencyConfig.sellingPoints.map(sp => ({ content: sp.content, required: sp.required, })), 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) } setIsSaving(false) toast.success('配置已保存!') return } catch (err) { console.error('保存 Brief 失败:', err) setIsSaving(false) toast.error('保存配置失败') return } } // Mock 模式 await new Promise(resolve => setTimeout(resolve, 1000)) setIsSaving(false) toast.success('配置已保存!') } // 卖点操作 const addSellingPoint = () => { if (!newSellingPoint.trim()) return setAgencyConfig(prev => ({ ...prev, sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }] })) setNewSellingPoint('') } const removeSellingPoint = (id: string) => { setAgencyConfig(prev => ({ ...prev, sellingPoints: prev.sellingPoints.filter(sp => sp.id !== id) })) } const toggleRequired = (id: string) => { setAgencyConfig(prev => ({ ...prev, sellingPoints: prev.sellingPoints.map(sp => sp.id === id ? { ...sp, required: !sp.required } : sp ) })) } // 违禁词操作 const addBlacklistWord = () => { if (!newBlacklistWord.trim()) return setAgencyConfig(prev => ({ ...prev, blacklistWords: [...prev.blacklistWords, { id: `bw${Date.now()}`, word: newBlacklistWord, reason: '自定义' }] })) setNewBlacklistWord('') } const removeBlacklistWord = (id: string) => { setAgencyConfig(prev => ({ ...prev, blacklistWords: prev.blacklistWords.filter(bw => bw.id !== id) })) } // 上传单个代理商文件 const uploadSingleAgencyFile = 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 newFile: AgencyFile = { id: fileId, name: file.name, size: formatFileSize(file.size), uploadedAt: new Date().toISOString().split('T')[0], } setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] })) 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 newFile: AgencyFile = { 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] })) 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 retryAgencyFileUpload = (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 )) uploadSingleAgencyFile(item.file, fileId) } const removeUploadingFile = (id: string) => { setUploadingFiles(prev => prev.filter(f => f.id !== id)) } // 代理商文档操作 const handleUploadAgencyFile = (e?: React.ChangeEvent) => { if (!e) { agencyFileInputRef.current?.click() return } const files = e.target.files if (!files || files.length === 0) return const fileList = Array.from(files) e.target.value = '' const newItems: UploadingFileItem[] = fileList.map(file => ({ id: `af-${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 => uploadSingleAgencyFile(item.file!, item.id)) } const removeAgencyFile = (id: string) => { setAgencyConfig(prev => ({ ...prev, agencyFiles: prev.agencyFiles.filter(f => f.id !== id) })) } const [previewAgencyUrl, setPreviewAgencyUrl] = useState(null) const [previewAgencyLoading, setPreviewAgencyLoading] = useState(false) const handlePreviewAgencyFile = async (file: AgencyFile) => { setPreviewAgencyFile(file) setPreviewAgencyUrl(null) if (!USE_MOCK && file.url) { setPreviewAgencyLoading(true) try { const signedUrl = await api.getSignedUrl(file.url) setPreviewAgencyUrl(signedUrl) } catch { toast.error('获取预览链接失败') } finally { setPreviewAgencyLoading(false) } } } const handleDownloadAgencyFile = async (file: AgencyFile) => { if (USE_MOCK || !file.url) { toast.info(`下载文件: ${file.name}`) return } try { const signedUrl = await api.getSignedUrl(file.url, 3600, true) window.open(signedUrl, '_blank') } catch { toast.error('获取下载链接失败') } } if (loading) { return } return (
{/* 顶部导航 */}

{brandBrief.projectName}

{platform && ( {platform.icon} {platform.name} )}

{brandBrief.brandName}

{showPlatformSelect && (
{platformSelectOptions.map((opt) => ( ))}
)}
{/* ===== 第一部分:品牌方 Brief(只读)===== */}

品牌方 Brief(只读)

以下是品牌方上传的 Brief 文件和规则,仅供参考,不可编辑。

{/* 品牌方文件 */} 品牌方 Brief 文件 {brandBrief.files.length} 个文件 {brandBrief.files.slice(0, 2).map((file) => (

{file.name}

{file.size} · {file.uploadedAt}

))} {brandBrief.files.length > 2 && ( )} {brandBrief.files.length === 0 && (

暂无 Brief 文件

)}
{/* 品牌方规则(只读) */} 品牌方限制

限制条件

{brandBrief.brandRules.restrictions}

竞品黑名单

{brandBrief.brandRules.competitors.map((c, i) => ( {c} ))} {brandBrief.brandRules.competitors.length === 0 && ( 暂无竞品 )}
{/* ===== 第二部分:代理商配置(可编辑)===== */}

代理商配置(可编辑)

以下配置由代理商编辑,将展示给达人查看。

{/* 代理商Brief文档管理 */} 代理商 Brief 文档 {agencyConfig.agencyFiles.length} 个文件(达人可见)
{agencyConfig.agencyFiles.map((file) => (

{file.name}

{file.size} · {file.uploadedAt}

{file.description && (

{file.description}

)}
))} {/* 上传中/失败的文件 */} {uploadingFiles.map((file) => (
{file.status === 'uploading' ? : }

{file.name}

{file.status === 'uploading' ? `${file.progress}% · ${file.size}` : file.size}

{file.status === 'uploading' && (
)} {file.status === 'error' && file.error && (

{file.error}

)}
{file.status === 'error' && (
)}
))} {/* 上传占位卡片 */}

以上文档将展示给达人查看,请确保内容准确完整。

{/* 左侧:AI解析 + 卖点配置 */}
{/* AI 解析结果 */} AI 解析结果

产品名称

{agencyConfig.aiParsedContent.productName}

目标人群

{agencyConfig.aiParsedContent.targetAudience}

内容要求

{agencyConfig.aiParsedContent.contentRequirements}

{/* 卖点配置(可编辑) */} 卖点配置 {agencyConfig.sellingPoints.length} 个卖点 {agencyConfig.sellingPoints.map((sp) => (
{sp.content}
))}
setNewSellingPoint(e.target.value)} placeholder="添加新卖点..." className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" onKeyDown={(e) => e.key === 'Enter' && addSellingPoint()} />
{/* 平台规则 */} {rules.name}平台规则 {rules.rules.map((rule, index) => (

{rule.category}

{rule.items.map((item, i) => ( {item} ))}
))}
{/* 右侧:违禁词配置 */}
{/* 违禁词配置(可编辑) */} 违禁词配置 {agencyConfig.blacklistWords.length} 个 {agencyConfig.blacklistWords.map((bw) => (
{'\u300C'}{bw.word}{'\u300D'} {bw.reason}
))}
setNewBlacklistWord(e.target.value)} placeholder="添加违禁词..." className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" onKeyDown={(e) => e.key === 'Enter' && addBlacklistWord()} />
{/* 配置信息 */} 配置状态
状态 已配置
配置时间 {agencyConfig.configuredAt || '-'}
{/* 配置提示 */}

配置说明

  • • 必选卖点必须在内容中提及
  • • 违禁词会触发 AI 审核警告
  • • 此配置将展示给达人查看
{/* 文件列表弹窗 */} setShowFilesModal(false)} title="品牌方 Brief 文件" size="lg" >
{brandBrief.files.map((file) => (

{file.name}

{file.size} · 上传于 {file.uploadedAt}

))} {brandBrief.files.length === 0 && (

暂无文件

)}
{/* 文件预览弹窗(品牌方) */} { setPreviewFile(null); setPreviewUrl(null) }} title={previewFile?.name || '文件预览'} size="lg" >
{previewLoading ? (
加载预览中...
) : previewUrl && previewFile?.name.toLowerCase().endsWith('.pdf') ? (