Your Name 4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00

1355 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="flex-1">
<div className="h-6 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-32 bg-bg-elevated rounded mt-2" />
</div>
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
<div className="h-10 w-24 bg-bg-elevated rounded-lg" />
</div>
<div className="h-20 bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-48 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
<div className="h-20 bg-bg-elevated rounded-lg" />
<div className="h-48 bg-bg-elevated rounded-xl" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="h-40 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
<div className="h-64 bg-bg-elevated rounded-xl" />
</div>
</div>
)
}
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<HTMLInputElement>(null)
// 上传中的文件跟踪
const [uploadingFiles, setUploadingFiles] = useState<UploadingFileItem[]>([])
// 加载状态
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<BriefFile | null>(null)
const [previewAgencyFile, setPreviewAgencyFile] = useState<AgencyFile | null>(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<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(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<string, unknown> = {
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: 'douyin', // 后端暂无 platform 字段
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)
window.open(signedUrl, '_blank')
} catch {
toast.error('获取下载链接失败')
}
}
// 预览文件
const handlePreview = (file: BriefFile) => {
setPreviewFile(file)
}
// 导出平台规则文档
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<HTMLInputElement>) => {
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 handlePreviewAgencyFile = (file: AgencyFile) => {
setPreviewAgencyFile(file)
}
const handleDownloadAgencyFile = async (file: AgencyFile) => {
if (USE_MOCK || !file.url) {
toast.info(`下载文件: ${file.name}`)
return
}
try {
const signedUrl = await api.getSignedUrl(file.url)
window.open(signedUrl, '_blank')
} catch {
toast.error('获取下载链接失败')
}
}
if (loading) {
return <BriefDetailSkeleton />
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
<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">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-text-primary">{brandBrief.projectName}</h1>
{platform && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
</div>
<p className="text-sm text-text-secondary flex items-center gap-2 mt-1">
<Building2 size={14} />
{brandBrief.brandName}
</p>
</div>
<div className="relative" ref={platformDropdownRef}>
<Button
variant="secondary"
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
disabled={isCheckingConflicts}
>
{isCheckingConflicts ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Search size={16} />
</>
)}
</Button>
{showPlatformSelect && (
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
{platformSelectOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleCheckConflicts(opt.value)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
<Button variant="secondary" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={16} />
{isExporting ? '导出中...' : '导出规则'}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
{/* ===== 第一部分:品牌方 Brief只读===== */}
<div className="p-4 bg-purple-500/10 rounded-lg border border-purple-500/30">
<div className="flex items-start gap-3">
<Building2 size={20} className="text-purple-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-purple-400 font-medium"> Brief</p>
<p className="text-sm text-purple-400/80 mt-1">
Brief
</p>
</div>
</div>
</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 className="flex items-center gap-2">
<FileText size={18} className="text-purple-400" />
Brief
<span className="text-sm font-normal text-text-secondary">
{brandBrief.files.length}
</span>
</span>
<Button variant="secondary" size="sm" onClick={() => setShowFilesModal(true)}>
<Eye size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{brandBrief.files.slice(0, 2).map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={20} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary text-sm">{file.name}</p>
<p className="text-xs text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
{brandBrief.files.length > 2 && (
<button
type="button"
onClick={() => setShowFilesModal(true)}
className="w-full p-3 text-sm text-purple-400 hover:bg-purple-500/5 rounded-lg transition-colors"
>
{brandBrief.files.length}
</button>
)}
{brandBrief.files.length === 0 && (
<div className="py-8 text-center">
<FileText size={32} className="mx-auto text-text-tertiary mb-2" />
<p className="text-sm text-text-secondary"> Brief </p>
</div>
)}
</CardContent>
</Card>
{/* 品牌方规则(只读) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<p className="text-sm text-text-primary">{brandBrief.brandRules.restrictions}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<div className="flex flex-wrap gap-2">
{brandBrief.brandRules.competitors.map((c, i) => (
<span key={i} className="px-2 py-1 text-xs bg-orange-500/15 text-orange-400 rounded border border-orange-500/30">
{c}
</span>
))}
{brandBrief.brandRules.competitors.length === 0 && (
<span className="text-sm text-text-tertiary"></span>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* ===== 第二部分:代理商配置(可编辑)===== */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<p className="text-sm text-accent-indigo/80 mt-1">
</p>
</div>
</div>
</div>
{/* 代理商Brief文档管理 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<File size={18} className="text-accent-indigo" />
Brief
<span className="text-sm font-normal text-text-secondary">
{agencyConfig.agencyFiles.length}
</span>
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowAgencyFilesModal(true)}>
<Eye size={14} />
</Button>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="p-4 bg-accent-indigo/5 rounded-lg border border-accent-indigo/20 hover:border-accent-indigo/40 transition-colors">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text-primary text-sm truncate">{file.name}</p>
<p className="text-xs text-text-tertiary mt-0.5">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Button variant="ghost" size="sm" onClick={() => handlePreviewAgencyFile(file)} className="flex-1">
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownloadAgencyFile(file)} className="flex-1">
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
<div key={file.id} className="p-4 rounded-lg border border-accent-indigo/20 bg-accent-indigo/5">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
{file.status === 'uploading'
? <Loader2 size={20} className="animate-spin text-accent-indigo" />
: <AlertCircle size={20} className="text-accent-coral" />
}
</div>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'}`}>
{file.name}
</p>
<p className="text-xs text-text-tertiary mt-0.5">
{file.status === 'uploading' ? `${file.progress}% · ${file.size}` : file.size}
</p>
{file.status === 'uploading' && (
<div className="mt-2 h-1.5 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300"
style={{ width: `${file.progress}%` }} />
</div>
)}
{file.status === 'error' && file.error && (
<p className="mt-1 text-xs text-accent-coral">{file.error}</p>
)}
</div>
</div>
{file.status === 'error' && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Button variant="ghost" size="sm" onClick={() => retryAgencyFileUpload(file.id)} className="flex-1">
<RotateCcw size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeUploadingFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
)}
</div>
))}
{/* 上传占位卡片 */}
<button
type="button"
onClick={() => handleUploadAgencyFile()}
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
>
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
<Plus size={20} className="text-text-tertiary" />
</div>
<span className="text-sm text-text-secondary"></span>
</button>
</div>
<div className="mt-4 p-3 bg-accent-indigo/10 rounded-lg border border-accent-indigo/20">
<p className="text-xs text-accent-indigo flex items-center gap-2">
<Info size={14} />
</p>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧AI解析 + 卖点配置 */}
<div className="lg:col-span-2 space-y-6">
{/* AI 解析结果 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Sparkles size={18} className="text-purple-400" />
AI
</span>
<Button variant="secondary" size="sm" onClick={handleAIParse} disabled={isAIParsing}>
<Sparkles size={14} />
{isAIParsing ? '解析中...' : '重新解析'}
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.productName}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.targetAudience}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg col-span-2">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{agencyConfig.aiParsedContent.contentRequirements}</p>
</div>
</div>
</CardContent>
</Card>
{/* 卖点配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target size={18} className="text-accent-green" />
<span className="text-sm font-normal text-text-secondary ml-2">
{agencyConfig.sellingPoints.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{agencyConfig.sellingPoints.map((sp) => (
<div key={sp.id} className="flex items-center gap-3 p-3 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => toggleRequired(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.required ? '必选' : '可选'}
</button>
<span className="flex-1 text-text-primary">{sp.content}</span>
<button
type="button"
onClick={() => removeSellingPoint(sp.id)}
className="p-1 hover:bg-bg-page rounded"
>
<X size={16} className="text-text-tertiary" />
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newSellingPoint}
onChange={(e) => 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()}
/>
<Button variant="secondary" onClick={addSellingPoint}>
<Plus size={16} />
</Button>
</div>
</CardContent>
</Card>
{/* 平台规则 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-amber" />
{rules.name}
</span>
<Button variant="secondary" size="sm" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rules.rules.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">
{rule.items.map((item, i) => (
<span key={i} className="px-2 py-1 text-xs bg-accent-amber/15 text-accent-amber rounded border border-accent-amber/30">
{item}
</span>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* 右侧:违禁词配置 */}
<div className="space-y-6">
{/* 违禁词配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Ban size={18} className="text-accent-coral" />
<span className="text-sm font-normal text-text-secondary ml-2">
{agencyConfig.blacklistWords.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{agencyConfig.blacklistWords.map((bw) => (
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div>
<span className="font-medium text-accent-coral">{'\u300C'}{bw.word}{'\u300D'}</span>
<span className="text-xs text-text-tertiary ml-2">{bw.reason}</span>
</div>
<button
type="button"
onClick={() => removeBlacklistWord(bw.id)}
className="p-1 hover:bg-accent-coral/20 rounded"
>
<X size={14} className="text-text-tertiary" />
</button>
</div>
))}
<div className="flex gap-2 mt-3">
<input
type="text"
value={newBlacklistWord}
onChange={(e) => 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()}
/>
<Button variant="secondary" size="sm" onClick={addBlacklistWord}>
<Plus size={14} />
</Button>
</div>
</CardContent>
</Card>
{/* 配置信息 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock size={18} className="text-text-tertiary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<SuccessTag></SuccessTag>
</div>
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<span className="text-text-primary">{agencyConfig.configuredAt || '-'}</span>
</div>
</CardContent>
</Card>
{/* 配置提示 */}
<div className="p-4 bg-accent-green/10 rounded-lg border border-accent-green/30">
<div className="flex items-start gap-3">
<CheckCircle size={20} className="text-accent-green flex-shrink-0 mt-0.5" />
<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> AI </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
</div>
{/* 文件列表弹窗 */}
<Modal
isOpen={showFilesModal}
onClose={() => setShowFilesModal(false)}
title="品牌方 Brief 文件"
size="lg"
>
<div className="space-y-3">
{brandBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={24} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
{brandBrief.files.length === 0 && (
<div className="py-12 text-center">
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
</div>
)}
</div>
</Modal>
{/* 文件预览弹窗(品牌方) */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
{/* 代理商文档管理弹窗 */}
<Modal
isOpen={showAgencyFilesModal}
onClose={() => setShowAgencyFilesModal(false)}
title="管理代理商 Brief 文档"
size="lg"
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-text-secondary">
</p>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
<div className="space-y-3">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<FileText size={24} className="text-accent-indigo" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-tertiary mt-1">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreviewAgencyFile(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownloadAgencyFile(file)}>
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{agencyConfig.agencyFiles.length === 0 && (
<div className="py-12 text-center">
<File size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
)}
</div>
</div>
</Modal>
{/* 代理商文档预览弹窗 */}
<Modal
isOpen={!!previewAgencyFile}
onClose={() => setPreviewAgencyFile(null)}
title={previewAgencyFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewAgencyFile(null)}>
</Button>
{previewAgencyFile && (
<Button onClick={() => handleDownloadAgencyFile(previewAgencyFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
{/* 隐藏的文件上传 input */}
<input
ref={agencyFileInputRef}
type="file"
multiple
onChange={handleUploadAgencyFile}
className="hidden"
/>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{ruleConflicts.length === 0 ? (
<div className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
<p className="text-text-primary font-medium"></p>
<p className="text-sm text-text-secondary mt-1">Brief </p>
</div>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
<p className="text-sm text-accent-amber">
{ruleConflicts.length}
</p>
</div>
{ruleConflicts.map((conflict, index) => (
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
</div>
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
</div>
</div>
))}
</>
)}
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
</Button>
</div>
</div>
</Modal>
</div>
)
}