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

1046 lines
42 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 } 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 (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="h-10 w-10 bg-bg-elevated rounded-lg" />
<div className="space-y-2">
<div className="h-7 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-32 bg-bg-elevated rounded" />
</div>
</div>
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-16 bg-bg-elevated rounded-xl" />
))}
</div>
)
}
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<UploadFileItem[]>([])
// 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<number | undefined>()
const [maxDuration, setMaxDuration] = useState<number | undefined>()
const [sellingPoints, setSellingPoints] = useState<SellingPoint[]>([])
const [blacklistWords, setBlacklistWords] = useState<BlacklistWord[]>([])
const [competitors, setCompetitors] = useState<string[]>([])
const [attachments, setAttachments] = useState<BriefAttachment[]>([])
// Rules state (local only — no per-project backend API yet)
const [rules, setRules] = useState(mockRules)
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState<string | null>('brief')
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [conflicts, setConflicts] = useState<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(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<string, unknown> = {
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<HTMLInputElement>) => {
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 }) => (
<button
type="button"
onClick={() => setActiveSection(activeSection === section ? null : section)}
className="w-full flex items-center justify-between p-4 hover:bg-bg-elevated/50 rounded-xl transition-colors"
>
<span className="flex items-center gap-2 font-semibold text-text-primary">
<Icon size={18} className="text-accent-indigo" />
{title}
</span>
{activeSection === section ? (
<ChevronUp size={18} className="text-text-tertiary" />
) : (
<ChevronDown size={18} className="text-text-tertiary" />
)}
</button>
)
if (loading) return <ConfigSkeleton />
return (
<div className="space-y-6">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
>
<ArrowLeft size={20} className="text-text-secondary" />
</button>
<div>
<h1 className="text-2xl font-bold text-text-primary">Brief和规则配置</h1>
<p className="text-sm text-text-secondary mt-0.5">
{projectName || `项目 ${projectId}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<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">
{platformOptions.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="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
</div>
</div>
{/* Brief配置 */}
<Card>
<SectionHeader title="Brief配置" icon={FileText} section="brief" />
{activeSection === 'brief' && (
<CardContent className="space-y-6 pt-0">
{/* 品牌调性 + 视频时长 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
value={brandTone}
onChange={(e) => setBrandTone(e.target.value)}
placeholder="例如:年轻、活力、清新"
/>
</div>
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
value={minDuration ?? ''}
onChange={(e) => setMinDuration(e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="最短"
/>
<span className="text-text-tertiary">~</span>
<Input
type="number"
min={0}
value={maxDuration ?? ''}
onChange={(e) => setMaxDuration(e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="最长"
/>
</div>
</div>
</div>
{/* 其他要求 */}
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<textarea
value={otherRequirements}
onChange={(e) => setOtherRequirements(e.target.value)}
placeholder="简要描述项目要求..."
className="w-full h-24 p-3 rounded-xl bg-bg-elevated border border-border-subtle text-text-primary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
{/* 卖点 / 创作要求 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"> / </label>
<div className="space-y-2">
{sellingPoints.map((sp, index) => (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-bg-elevated">
<button
type="button"
onClick={() => toggleSellingPointRequired(index)}
title={sp.required ? '必选卖点(点击切换)' : '可选卖点(点击切换)'}
>
<CheckCircle size={16} className={sp.required ? 'text-accent-green' : 'text-text-tertiary'} />
</button>
<span className="flex-1 text-text-primary">{sp.content}</span>
{sp.required && <span className="text-xs text-accent-green"></span>}
<button
type="button"
onClick={() => removeSellingPoint(index)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
<div className="flex gap-2">
<Input
value={newSellingPoint}
onChange={(e) => setNewSellingPoint(e.target.value)}
placeholder="添加卖点或创作要求"
onKeyDown={(e) => e.key === 'Enter' && addSellingPoint()}
/>
<Button variant="secondary" onClick={addSellingPoint}>
<Plus size={16} />
</Button>
</div>
</div>
</div>
{/* 禁止词 */}
<div>
<label className="text-sm text-text-secondary mb-2 block flex items-center gap-2">
<AlertTriangle size={14} className="text-accent-coral" />
</label>
<div className="space-y-2 mb-3">
{blacklistWords.map((bw, index) => (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-bg-elevated">
<span className="text-accent-coral font-medium">{bw.word}</span>
{bw.reason && <span className="text-xs text-text-tertiary"> {bw.reason}</span>}
<div className="flex-1" />
<button
type="button"
onClick={() => removeBlacklistWord(index)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
<div className="flex gap-2">
<Input
value={newBlacklistWord}
onChange={(e) => setNewBlacklistWord(e.target.value)}
placeholder="禁止词"
onKeyDown={(e) => e.key === 'Enter' && addBlacklistWord()}
/>
<Input
value={newBlacklistReason}
onChange={(e) => setNewBlacklistReason(e.target.value)}
placeholder="原因(可选)"
onKeyDown={(e) => e.key === 'Enter' && addBlacklistWord()}
/>
<Button variant="secondary" onClick={addBlacklistWord}>
<Plus size={16} />
</Button>
</div>
</div>
{/* 竞品品牌 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="flex flex-wrap gap-2 mb-3">
{competitors.map((name) => (
<span
key={name}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full bg-accent-coral/15 text-accent-coral text-sm"
>
{name}
<button
type="button"
onClick={() => removeCompetitor(name)}
className="hover:text-accent-coral/70 transition-colors"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
value={newCompetitor}
onChange={(e) => setNewCompetitor(e.target.value)}
placeholder="添加竞品品牌名称"
onKeyDown={(e) => e.key === 'Enter' && addCompetitorItem()}
/>
<Button variant="secondary" onClick={addCompetitorItem}>
<Plus size={16} />
</Button>
</div>
</div>
{/* 参考资料 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-dashed border-border-subtle bg-bg-elevated text-text-primary hover:border-accent-indigo/50 hover:bg-bg-page transition-colors cursor-pointer w-full text-sm mb-3">
<Upload size={16} className="text-accent-indigo" />
<input
type="file"
multiple
onChange={handleAttachmentUpload}
className="hidden"
/>
</label>
{/* 文件列表 */}
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary flex items-center gap-1.5">
<FileText size={12} className="text-accent-indigo" />
</span>
<span className="text-xs text-text-tertiary">
{attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length}
{uploadingFiles.some(f => f.status === 'uploading') && (
<span className="text-accent-indigo ml-1">· </span>
)}
</span>
</div>
{attachments.length === 0 && uploadingFiles.length === 0 ? (
<div className="px-4 py-5 text-center">
<p className="text-xs text-text-tertiary"></p>
</div>
) : (
<div className="divide-y divide-border-subtle">
{/* 已完成的文件 */}
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
<div key={file.id} className="px-4 py-2.5">
<div className="flex items-center gap-3">
{file.status === 'uploading' && (
<Loader2 size={14} className="animate-spin text-accent-indigo flex-shrink-0" />
)}
{file.status === 'error' && (
<AlertCircle size={14} className="text-accent-coral flex-shrink-0" />
)}
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className={`flex-1 text-sm truncate ${
file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'
}`}>{file.name}</span>
<span className="text-xs text-text-tertiary whitespace-nowrap min-w-[40px] text-right">
{file.status === 'uploading' ? `${file.progress}%` : file.size}
</span>
{file.status === 'error' && (
<button type="button" onClick={() => retryAttachmentUpload(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-accent-indigo transition-colors" title="重试">
<RotateCcw size={14} />
</button>
)}
{file.status !== 'uploading' && (
<button type="button" onClick={() => removeUploadingFile(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors" title="删除">
<Trash2 size={14} />
</button>
)}
</div>
{file.status === 'uploading' && (
<div className="mt-1.5 ml-[28px] h-2 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 ml-[28px] text-xs text-accent-coral">{file.error}</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
)}
</Card>
{/* AI审核规则 */}
<Card>
<SectionHeader title="AI审核规则" icon={Bot} section="ai" />
{activeSection === 'ai' && (
<CardContent className="space-y-6 pt-0">
{/* AI审核开关 */}
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary">AI自动审核</p>
<p className="text-sm text-text-secondary">AI预审</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, aiReview: { ...rules.aiReview, enabled: !rules.aiReview.enabled } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.aiReview.enabled ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.aiReview.enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{rules.aiReview.enabled && (
<>
{/* 严格程度 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="grid grid-cols-3 gap-3">
{strictnessOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRules({ ...rules, aiReview: { ...rules.aiReview, strictness: option.value } })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
rules.aiReview.strictness === option.value
? 'border-accent-indigo bg-accent-indigo/10'
: 'border-border-subtle hover:border-border-subtle/80'
}`}
>
<p className={`font-medium ${rules.aiReview.strictness === option.value ? 'text-accent-indigo' : 'text-text-primary'}`}>
{option.label}
</p>
<p className="text-xs text-text-tertiary mt-1">{option.description}</p>
</button>
))}
</div>
</div>
{/* 检测项目 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
{rules.aiReview.checkItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 rounded-lg bg-bg-elevated"
>
<span className="text-text-primary">{item.name}</span>
<button
type="button"
onClick={() => toggleAiCheckItem(item.id)}
className={`relative w-10 h-5 rounded-full transition-colors ${
item.enabled ? 'bg-accent-green' : 'bg-bg-page'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
item.enabled ? 'left-5' : 'left-0.5'
}`}
/>
</button>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
)}
</Card>
{/* 人工审核规则 */}
<Card>
<SectionHeader title="人工审核规则" icon={Users} section="manual" />
{activeSection === 'manual' && (
<CardContent className="space-y-4 pt-0">
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, scriptRequired: !rules.manualReview.scriptRequired } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.scriptRequired ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.scriptRequired ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, videoRequired: !rules.manualReview.videoRequired } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.videoRequired ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.videoRequired ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary">/</p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, agencyCanApprove: !rules.manualReview.agencyCanApprove } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.agencyCanApprove ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.agencyCanApprove ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated">
<div>
<p className="font-medium text-text-primary"></p>
<p className="text-sm text-text-secondary"></p>
</div>
<button
type="button"
onClick={() => setRules({ ...rules, manualReview: { ...rules.manualReview, brandFinalReview: !rules.manualReview.brandFinalReview } })}
className={`relative w-12 h-6 rounded-full transition-colors ${
rules.manualReview.brandFinalReview ? 'bg-accent-indigo' : 'bg-bg-page'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
rules.manualReview.brandFinalReview ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</CardContent>
)}
</Card>
{/* 申诉规则 */}
<Card>
<SectionHeader title="申诉规则" icon={Shield} section="appeal" />
{activeSection === 'appeal' && (
<CardContent className="space-y-4 pt-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
type="number"
min={1}
max={10}
value={rules.appealRules.maxAppeals}
onChange={(e) => setRules({
...rules,
appealRules: { ...rules.appealRules, maxAppeals: parseInt(e.target.value) || 1 }
})}
/>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
<div>
<label className="text-sm text-text-secondary mb-1.5 block"></label>
<Input
type="number"
min={1}
max={168}
value={rules.appealRules.appealDeadline}
onChange={(e) => setRules({
...rules,
appealRules: { ...rules.appealRules, appealDeadline: parseInt(e.target.value) || 24 }
})}
/>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
</CardContent>
)}
</Card>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{conflicts.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">
{conflicts.length}
</p>
</div>
{conflicts.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>
)
}