后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则, 新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面 添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
926 lines
36 KiB
TypeScript
926 lines
36 KiB
TypeScript
'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,
|
||
CheckCircle,
|
||
Bot,
|
||
Users,
|
||
Save,
|
||
Upload,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Loader2,
|
||
Search
|
||
} 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 { useOSSUpload } from '@/hooks/useOSSUpload'
|
||
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
||
|
||
// ==================== 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,
|
||
},
|
||
}
|
||
|
||
// 严格程度选项
|
||
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 { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
|
||
|
||
// 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))
|
||
}
|
||
|
||
// Attachment upload
|
||
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (USE_MOCK) {
|
||
setAttachments([...attachments, {
|
||
id: `att-${Date.now()}`,
|
||
name: file.name,
|
||
url: `mock://${file.name}`,
|
||
}])
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = await upload(file)
|
||
setAttachments([...attachments, {
|
||
id: `att-${Date.now()}`,
|
||
name: file.name,
|
||
url: result.url,
|
||
}])
|
||
} catch {
|
||
toast.error('文件上传失败')
|
||
}
|
||
}
|
||
|
||
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>
|
||
<div className="space-y-2">
|
||
{attachments.map((att) => (
|
||
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
|
||
<FileText size={16} className="text-accent-indigo" />
|
||
<span className="flex-1 text-text-primary">{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-page text-text-tertiary hover:text-accent-coral transition-colors"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-border-subtle bg-bg-elevated text-text-primary hover:bg-bg-page transition-colors cursor-pointer w-full text-sm">
|
||
{isUploading ? (
|
||
<>
|
||
<Loader2 size={16} className="animate-spin" />
|
||
上传中 {uploadProgress}%
|
||
</>
|
||
) : (
|
||
<>
|
||
<Upload size={16} />
|
||
上传参考资料
|
||
</>
|
||
)}
|
||
<input
|
||
type="file"
|
||
onChange={handleAttachmentUpload}
|
||
className="hidden"
|
||
disabled={isUploading}
|
||
/>
|
||
</label>
|
||
</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>
|
||
)
|
||
}
|