'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, 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, ParsedRulesData } 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' import type { TaskResponse } from '@/types/task' import type { CreatorDetail } from '@/types/organization' import { mapTaskToUI } from '@/lib/taskStageMapper' // 文件类型 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++++', 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: [ { id: 'bw1', word: '最好', reason: '绝对化用语' }, { id: 'bw2', word: '第一', reason: '绝对化用语' }, { id: 'bw3', word: '神器', reason: '夸大宣传' }, { id: 'bw4', word: '完美', reason: '绝对化用语' }, ], } // 平台规则类型 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 { 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 [minSellingPoints, setMinSellingPoints] = useState(null) // 弹窗状态 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 [dynamicPlatformRules, setDynamicPlatformRules] = useState(mockPlatformRules) const [platformRuleName, setPlatformRuleName] = useState('') // 任务管理 const [projectTasks, setProjectTasks] = useState([]) const [availableCreators, setAvailableCreators] = useState([]) const [showCreatorModal, setShowCreatorModal] = useState(false) const [creatingTask, setCreatingTask] = useState(false) // 规则冲突检测 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 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) 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 模式使用默认数据 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 } 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) // 加载最少卖点数配置 if (brief?.min_selling_points != null) { setMinSellingPoints(brief.min_selling_points) } 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: (() => { // 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, priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference', })), blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, word: bw.word, 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 详情失败') } finally { setLoading(false) } }, [projectId, toast]) useEffect(() => { loadData() }, [loadData]) const platform = getPlatformInfo(brandBrief.platform) // 下载文件 const handleDownload = async (file: BriefFile) => { if (USE_MOCK || !file.url) { toast.info(`下载文件: ${file.name}`) return } try { await api.downloadFile(file.url, file.name) } 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 blobUrl = await api.getPreviewUrl(file.url) setPreviewUrl(blobUrl) } 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) 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) } } // 保存配置 const handleSave = async () => { setIsSaving(true) if (!USE_MOCK) { try { // 代理商通过专用 PATCH 端点保存 await api.updateBriefByAgency(projectId, { selling_points: agencyConfig.sellingPoints.map(sp => ({ content: sp.content, priority: sp.priority, })) as any, blacklist_words: agencyConfig.blacklistWords.map(bw => ({ word: bw.word, reason: bw.reason, })), agency_attachments: agencyConfig.agencyFiles.map(f => ({ id: f.id, name: f.name, url: f.url || '', size: f.size, })), min_selling_points: minSellingPoints, }) 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, priority: 'recommended' as const }] })) setNewSellingPoint('') } const removeSellingPoint = (id: string) => { setAgencyConfig(prev => ({ ...prev, sellingPoints: prev.sellingPoints.filter(sp => sp.id !== id) })) } const cyclePriority = (id: string) => { const order: Array<'core' | 'recommended' | 'reference'> = ['core', 'recommended', 'reference'] setAgencyConfig(prev => ({ ...prev, 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] } }) })) } // 违禁词操作 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 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) { 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 => { 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 : '上传失败' 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 => { const updated = prev.agencyFiles.filter(f => f.id !== id) // 删除后也自动保存 autoSaveAgencyFiles(updated) return { ...prev, agencyFiles: updated } }) } 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 blobUrl = await api.getPreviewUrl(file.url) setPreviewAgencyUrl(blobUrl) } catch { toast.error('获取预览链接失败') } finally { setPreviewAgencyLoading(false) } } } const handleDownloadAgencyFile = async (file: AgencyFile) => { if (USE_MOCK || !file.url) { toast.info(`下载文件: ${file.name}`) return } try { await api.downloadFile(file.url, file.name) } 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 && ( 暂无竞品 )}
{/* ===== 任务管理区块 ===== */} 项目任务 {projectTasks.length} 个任务 {projectTasks.length > 0 ? (
{projectTasks.map((task) => { const uiState = mapTaskToUI(task) return (
{task.creator.name.charAt(0)}
{task.name}
达人: {task.creator.name} 创建: {task.created_at.split('T')[0]}
{uiState.statusLabel}
) })}
) : (

暂无任务

点击「分配达人」创建任务

)}
{/* 达人选择弹窗 */} setShowCreatorModal(false)} title="选择达人" size="md" >

选择一位达人为其创建任务。同一达人可多次选择(用于拍摄多个视频)。

{availableCreators.length > 0 ? (
{availableCreators.map((creator) => { const taskCount = projectTasks.filter(t => t.creator.id === creator.id).length return ( ) })}
) : (

暂无可选达人

请先在「达人管理」中添加达人

)}
{/* ===== 第二部分:代理商配置(可编辑)===== */}

代理商配置(可编辑)

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

{/* 代理商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()} />
{/* 最少卖点数配置 */}

最少体现卖点数

AI 审核时按此数量计算覆盖率评分,不设置则默认要求覆盖全部核心+推荐卖点

{minSellingPoints ?? '-'} {minSellingPoints !== null && ( )}
{/* 平台规则 */} {platformRuleName || platform?.name || ''}平台规则 {dynamicPlatformRules.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); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null) } }} title={previewFile?.name || '文件预览'} size="lg" >
{previewLoading ? (
加载预览中...
) : previewUrl && previewFile?.name.toLowerCase().endsWith('.pdf') ? (