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

1253 lines
56 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 { Plus, Shield, Ban, Building2, Search, X, Upload, Trash2, FileText, Eye, Loader2, CheckCircle, Clock, AlertTriangle, Edit3 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
// upload via api.proxyUpload directly
import type {
ForbiddenWordResponse,
CompetitorResponse,
WhitelistResponse,
BrandPlatformRuleResponse,
ParsedRulesData,
} from '@/types/rules'
// ===== 平台图标映射 =====
const platformDisplayMap: Record<string, { icon: string; color: string; name: string }> = {
douyin: { icon: '🎵', color: 'bg-[#25F4EE]', name: '抖音' },
xiaohongshu: { icon: '📕', color: 'bg-[#fe2c55]', name: '小红书' },
bilibili: { icon: '📺', color: 'bg-[#00a1d6]', name: 'B站' },
kuaishou: { icon: '⚡', color: 'bg-[#ff4906]', name: '快手' },
weibo: { icon: '🔴', color: 'bg-[#e6162d]', name: '微博' },
wechat: { icon: '📱', color: 'bg-[#07c160]', name: '微信视频号' },
}
function getPlatformDisplay(platform: string) {
return platformDisplayMap[platform] || { icon: '📋', color: 'bg-gray-400', name: platform }
}
type IconComponent = typeof CheckCircle
const statusConfig: Record<string, { label: string; color: string; bg: string; icon: IconComponent }> = {
active: { label: '生效中', color: 'text-accent-green', bg: 'bg-accent-green/15', icon: CheckCircle },
draft: { label: '待确认', color: 'text-accent-amber', bg: 'bg-accent-amber/15', icon: Clock },
inactive: { label: '已停用', color: 'text-text-tertiary', bg: 'bg-bg-elevated', icon: AlertTriangle },
}
// ===== Mock 数据 =====
const mockPlatformRules: BrandPlatformRuleResponse[] = [
{
id: 'pr-mock001',
platform: 'douyin',
brand_id: 'BR000001',
document_url: 'https://example.com/rules.pdf',
document_name: '抖音广告规则2024.pdf',
parsed_rules: {
forbidden_words: ['最好', '第一', '最佳', '绝对', '100%'],
restricted_words: [{ word: '效果显著', condition: '需要提供数据支撑', suggestion: '建议改为"改善效果"' }],
duration: { min_seconds: 7, max_seconds: 60 },
content_requirements: ['必须展示产品正面', '口播品牌名至少1次'],
other_rules: [{ rule: '水印要求', description: '视频不得包含第三方水印' }],
},
status: 'active',
created_at: '2024-02-01T10:00:00Z',
updated_at: '2024-02-01T10:30:00Z',
},
{
id: 'pr-mock002',
platform: 'xiaohongshu',
brand_id: 'BR000001',
document_url: 'https://example.com/xhs-rules.docx',
document_name: '小红书投放规范.docx',
parsed_rules: {
forbidden_words: ['最好', '绝对', '100%'],
restricted_words: [],
duration: null,
content_requirements: ['需要真实使用体验分享'],
other_rules: [],
},
status: 'active',
created_at: '2024-01-20T08:00:00Z',
updated_at: '2024-01-20T09:00:00Z',
},
{
id: 'pr-mock003',
platform: 'bilibili',
brand_id: 'BR000001',
document_url: 'https://example.com/bili-rules.xlsx',
document_name: 'B站违禁词清单.xlsx',
parsed_rules: {
forbidden_words: ['最好', '第一'],
restricted_words: [],
duration: { min_seconds: 15 },
content_requirements: [],
other_rules: [{ rule: '弹幕互动', description: '鼓励弹幕互动但不得刷屏' }],
},
status: 'draft',
created_at: '2024-02-03T14:00:00Z',
updated_at: '2024-02-03T14:00:00Z',
},
]
const mockForbiddenWords: ForbiddenWordResponse[] = [
{ id: '1', word: '最好', category: '极限词', severity: 'high' },
{ id: '2', word: '第一', category: '极限词', severity: 'high' },
{ id: '3', word: '最佳', category: '极限词', severity: 'high' },
{ id: '4', word: '100%有效', category: '虚假宣称', severity: 'critical' },
{ id: '5', word: '立即见效', category: '虚假宣称', severity: 'critical' },
{ id: '6', word: '永久', category: '极限词', severity: 'medium' },
{ id: '7', word: '绝对', category: '极限词', severity: 'medium' },
{ id: '8', word: '最低价', category: '价格欺诈', severity: 'high' },
]
const mockCompetitors: CompetitorResponse[] = [
{ id: '1', name: '竞品A', brand_id: '', keywords: ['竞品A', '品牌A'] },
{ id: '2', name: '竞品B', brand_id: '', keywords: ['竞品B', '品牌B'] },
{ id: '3', name: '竞品C', brand_id: '', keywords: ['竞品C', '品牌C'] },
]
const mockWhitelist: WhitelistResponse[] = [
{ id: '1', term: '品牌专属术语1', reason: '品牌授权使用', brand_id: '' },
{ id: '2', term: '特定产品名', reason: '官方产品名称', brand_id: '' },
]
const categoryOptions = [
{ value: '极限词', label: '极限词' },
{ value: '虚假宣称', label: '虚假宣称' },
{ value: '价格欺诈', label: '价格欺诈' },
{ value: '平台规则', label: '平台规则' },
{ value: '自定义', label: '自定义' },
]
// ===== Loading Skeleton 组件 =====
function CardSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="h-6 bg-bg-elevated rounded w-1/4" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 rounded-xl border border-border-subtle">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-bg-elevated rounded-xl" />
<div className="space-y-2 flex-1">
<div className="h-4 bg-bg-elevated rounded w-1/2" />
<div className="h-3 bg-bg-elevated rounded w-1/3" />
</div>
</div>
<div className="h-3 bg-bg-elevated rounded w-2/3 mb-3" />
<div className="h-3 bg-bg-elevated rounded w-1/2" />
</div>
))}
</div>
</div>
)
}
function WordsSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="flex gap-3">
<div className="h-10 bg-bg-elevated rounded-xl flex-1 max-w-md" />
<div className="h-10 bg-bg-elevated rounded-xl w-32" />
</div>
{[1, 2].map((group) => (
<div key={group} className="space-y-2">
<div className="h-4 bg-bg-elevated rounded w-20" />
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-8 bg-bg-elevated rounded-lg w-20" />
))}
</div>
</div>
))}
</div>
)
}
function ListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="animate-pulse space-y-3">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 rounded-xl border border-border-subtle">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-bg-elevated rounded-lg" />
<div className="h-4 bg-bg-elevated rounded w-24" />
</div>
<div className="w-8 h-8 bg-bg-elevated rounded-lg" />
</div>
))}
</div>
)
}
// ===== 主组件 =====
export default function RulesPage() {
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isOssUploading, setIsOssUploading] = useState(false)
const [ossProgress, setOssProgress] = useState(0)
// Tab 选择
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
const [searchQuery, setSearchQuery] = useState('')
// 数据状态
const [forbiddenWords, setForbiddenWords] = useState<ForbiddenWordResponse[]>([])
const [competitors, setCompetitors] = useState<CompetitorResponse[]>([])
const [whitelist, setWhitelist] = useState<WhitelistResponse[]>([])
const [platformRules, setPlatformRules] = useState<BrandPlatformRuleResponse[]>([])
// 加载状态
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [parsing, setParsing] = useState(false)
// 上传规则文档
const [showUploadModal, setShowUploadModal] = useState(false)
const [uploadPlatform, setUploadPlatform] = useState('')
const [uploadFile, setUploadFile] = useState<File | null>(null)
// 查看/编辑解析结果
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedRule, setSelectedRule] = useState<BrandPlatformRuleResponse | null>(null)
const [editingRules, setEditingRules] = useState<ParsedRulesData | null>(null)
const [editingForbiddenInput, setEditingForbiddenInput] = useState('')
// 添加违禁词
const [showAddWordModal, setShowAddWordModal] = useState(false)
const [newWord, setNewWord] = useState('')
const [newCategory, setNewCategory] = useState('极限词')
const [batchWords, setBatchWords] = useState('')
// 添加竞品
const [showAddCompetitorModal, setShowAddCompetitorModal] = useState(false)
const [newCompetitor, setNewCompetitor] = useState('')
// 添加白名单
const [showAddWhitelistModal, setShowAddWhitelistModal] = useState(false)
const [newWhitelistTerm, setNewWhitelistTerm] = useState('')
const [newWhitelistReason, setNewWhitelistReason] = useState('')
// ===== 数据加载 =====
const loadForbiddenWords = useCallback(async () => {
if (USE_MOCK) { setForbiddenWords(mockForbiddenWords); return }
try {
const res = await api.listForbiddenWords()
setForbiddenWords(res.items)
} catch (err) {
toast.error('加载违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
}
}, [toast])
const loadCompetitors = useCallback(async () => {
if (USE_MOCK) { setCompetitors(mockCompetitors); return }
try {
const res = await api.listCompetitors()
setCompetitors(res.items)
} catch (err) {
toast.error('加载竞品列表失败:' + (err instanceof Error ? err.message : '未知错误'))
}
}, [toast])
const loadWhitelist = useCallback(async () => {
if (USE_MOCK) { setWhitelist(mockWhitelist); return }
try {
const res = await api.listWhitelist()
setWhitelist(res.items)
} catch (err) {
toast.error('加载白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
}
}, [toast])
const loadPlatformRules = useCallback(async () => {
if (USE_MOCK) { setPlatformRules(mockPlatformRules); return }
try {
const res = await api.listBrandPlatformRules()
setPlatformRules(res.items)
} catch (err) {
toast.error('加载平台规则失败:' + (err instanceof Error ? err.message : '未知错误'))
}
}, [toast])
const loadAllData = useCallback(async () => {
setLoading(true)
await Promise.all([loadForbiddenWords(), loadCompetitors(), loadWhitelist(), loadPlatformRules()])
setLoading(false)
}, [loadForbiddenWords, loadCompetitors, loadWhitelist, loadPlatformRules])
useEffect(() => { loadAllData() }, [loadAllData])
// ===== 过滤违禁词 =====
const filteredWords = forbiddenWords.filter(w =>
searchQuery === '' ||
w.word.toLowerCase().includes(searchQuery.toLowerCase()) ||
w.category.toLowerCase().includes(searchQuery.toLowerCase())
)
// ===== 平台规则操作 =====
const activeRulesCount = platformRules.filter(r => r.status === 'active').length
const handleUploadAndParse = async () => {
if (!uploadPlatform || !uploadFile) return
setParsing(true)
try {
let documentUrl: string
let documentName = uploadFile.name
if (USE_MOCK) {
// Mock: 模拟上传和解析
await new Promise(r => setTimeout(r, 2000))
const newRule: BrandPlatformRuleResponse = {
id: `pr-mock${Date.now()}`,
platform: uploadPlatform,
brand_id: 'BR000001',
document_url: 'https://mock.example.com/' + uploadFile.name,
document_name: uploadFile.name,
parsed_rules: {
forbidden_words: ['最好', '第一', '100%'],
restricted_words: [{ word: '效果好', condition: '需提供证据', suggestion: '改为"改善效果"' }],
duration: { min_seconds: 7 },
content_requirements: ['必须展示产品'],
other_rules: [],
},
status: 'draft',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setPlatformRules(prev => [newRule, ...prev])
setShowUploadModal(false)
setUploadPlatform('')
setUploadFile(null)
toast.success('文档解析完成,请确认解析结果')
// 打开详情编辑
setSelectedRule(newRule)
setEditingRules(newRule.parsed_rules)
setShowDetailModal(true)
setParsing(false)
return
}
// 真实模式: 上传到 TOS (通过后端代理)
setIsOssUploading(true)
setOssProgress(0)
const uploadResult = await api.proxyUpload(uploadFile, 'rules', (pct) => {
setOssProgress(Math.min(95, Math.round(pct * 0.95)))
})
setOssProgress(100)
setIsOssUploading(false)
documentUrl = uploadResult.url
// 调用 AI 解析
const parsed = await api.parsePlatformRule({
document_url: documentUrl,
document_name: documentName,
platform: uploadPlatform,
brand_id: '', // 后端从 token 获取
})
await loadPlatformRules()
setShowUploadModal(false)
setUploadPlatform('')
setUploadFile(null)
toast.success('文档解析完成,请确认解析结果')
// 打开详情编辑
const newRule: BrandPlatformRuleResponse = {
id: parsed.id,
platform: parsed.platform,
brand_id: parsed.brand_id,
document_url: parsed.document_url,
document_name: parsed.document_name,
parsed_rules: parsed.parsed_rules,
status: parsed.status,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setSelectedRule(newRule)
setEditingRules(parsed.parsed_rules)
setShowDetailModal(true)
} catch (err) {
toast.error('文档解析失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setParsing(false)
setIsOssUploading(false)
}
}
const handleConfirmRule = async () => {
if (!selectedRule || !editingRules) return
setSubmitting(true)
try {
if (USE_MOCK) {
setPlatformRules(prev => prev.map(r =>
r.id === selectedRule.id
? { ...r, parsed_rules: editingRules, status: 'active', updated_at: new Date().toISOString() }
: r.platform === selectedRule.platform && r.status === 'active'
? { ...r, status: 'inactive' }
: r
))
} else {
await api.confirmPlatformRule(selectedRule.id, { parsed_rules: editingRules })
await loadPlatformRules()
}
toast.success('规则已确认生效')
setShowDetailModal(false)
setSelectedRule(null)
setEditingRules(null)
} catch (err) {
toast.error('确认失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const handleDeleteRule = async (ruleId: string) => {
setSubmitting(true)
try {
if (USE_MOCK) {
setPlatformRules(prev => prev.filter(r => r.id !== ruleId))
} else {
await api.deletePlatformRule(ruleId)
await loadPlatformRules()
}
toast.success('规则已删除')
} catch (err) {
toast.error('删除失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const viewRuleDetail = (rule: BrandPlatformRuleResponse) => {
setSelectedRule(rule)
setEditingRules({ ...rule.parsed_rules })
setShowDetailModal(true)
}
// ===== 编辑解析结果辅助 =====
const addForbiddenWord = () => {
if (!editingForbiddenInput.trim() || !editingRules) return
setEditingRules({
...editingRules,
forbidden_words: [...editingRules.forbidden_words, editingForbiddenInput.trim()],
})
setEditingForbiddenInput('')
}
const removeForbiddenWord = (index: number) => {
if (!editingRules) return
setEditingRules({
...editingRules,
forbidden_words: editingRules.forbidden_words.filter((_, i) => i !== index),
})
}
const removeContentReq = (index: number) => {
if (!editingRules) return
setEditingRules({
...editingRules,
content_requirements: editingRules.content_requirements.filter((_, i) => i !== index),
})
}
// ===== 违禁词操作 =====
const handleAddWord = async () => {
if (!newWord.trim()) return
setSubmitting(true)
try {
if (USE_MOCK) {
setForbiddenWords(prev => [...prev, { id: Date.now().toString(), word: newWord.trim(), category: newCategory, severity: 'medium' }])
} else {
await api.addForbiddenWord({ word: newWord.trim(), category: newCategory, severity: 'medium' })
await loadForbiddenWords()
}
toast.success('违禁词添加成功')
setNewWord('')
setShowAddWordModal(false)
} catch (err) {
toast.error('添加违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const handleBatchAdd = async () => {
const words = batchWords.split('\n').filter(w => w.trim())
if (words.length === 0) return
setSubmitting(true)
try {
if (USE_MOCK) {
const newWords: ForbiddenWordResponse[] = words.map((word, i) => ({ id: `${Date.now()}-${i}`, word: word.trim(), category: newCategory, severity: 'medium' }))
setForbiddenWords(prev => [...prev, ...newWords])
} else {
for (const word of words) {
await api.addForbiddenWord({ word: word.trim(), category: newCategory, severity: 'medium' })
}
await loadForbiddenWords()
}
toast.success(`成功添加 ${words.length} 个违禁词`)
setBatchWords('')
setShowAddWordModal(false)
} catch (err) {
toast.error('批量添加违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const handleDeleteWord = async (id: string) => {
setSubmitting(true)
try {
if (USE_MOCK) { setForbiddenWords(prev => prev.filter(w => w.id !== id)) }
else { await api.deleteForbiddenWord(id); await loadForbiddenWords() }
toast.success('违禁词已删除')
} catch (err) {
toast.error('删除违禁词失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
// ===== 竞品操作 =====
const handleAddCompetitor = async () => {
if (!newCompetitor.trim()) return
setSubmitting(true)
try {
if (USE_MOCK) {
setCompetitors(prev => [...prev, { id: Date.now().toString(), name: newCompetitor.trim(), brand_id: '', keywords: [newCompetitor.trim()] }])
} else {
await api.addCompetitor({ name: newCompetitor.trim(), brand_id: '', keywords: [newCompetitor.trim()] })
await loadCompetitors()
}
toast.success('竞品添加成功')
setNewCompetitor('')
setShowAddCompetitorModal(false)
} catch (err) {
toast.error('添加竞品失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const handleDeleteCompetitor = async (id: string) => {
setSubmitting(true)
try {
if (USE_MOCK) { setCompetitors(prev => prev.filter(c => c.id !== id)) }
else { await api.deleteCompetitor(id); await loadCompetitors() }
toast.success('竞品已删除')
} catch (err) {
toast.error('删除竞品失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
// ===== 白名单操作 =====
const handleAddWhitelist = async () => {
if (!newWhitelistTerm.trim()) return
setSubmitting(true)
try {
if (USE_MOCK) {
setWhitelist(prev => [...prev, { id: Date.now().toString(), term: newWhitelistTerm.trim(), reason: newWhitelistReason.trim(), brand_id: '' }])
} else {
await api.addToWhitelist({ term: newWhitelistTerm.trim(), reason: newWhitelistReason.trim(), brand_id: '' })
await loadWhitelist()
}
toast.success('白名单添加成功')
setNewWhitelistTerm('')
setNewWhitelistReason('')
setShowAddWhitelistModal(false)
} catch (err) {
toast.error('添加白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
const handleDeleteWhitelist = async (id: string) => {
setSubmitting(true)
try {
if (USE_MOCK) { setWhitelist(prev => prev.filter(w => w.id !== id)) }
else { setWhitelist(prev => prev.filter(w => w.id !== id)) }
toast.success('白名单已删除')
} catch (err) {
toast.error('删除白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
{/* 标签页 */}
<div className="flex gap-1 p-1 bg-bg-elevated rounded-xl w-fit">
<button
type="button"
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
activeTab === 'platforms' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
onClick={() => setActiveTab('platforms')}
>
<FileText size={16} />
<span className="px-2 py-0.5 rounded-full bg-accent-indigo/15 text-accent-indigo text-xs">
{activeRulesCount}
</span>
</button>
<button
type="button"
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
activeTab === 'forbidden' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
onClick={() => setActiveTab('forbidden')}
>
<Ban size={16} />
<span className="px-2 py-0.5 rounded-full bg-accent-coral/15 text-accent-coral text-xs">
{forbiddenWords.length}
</span>
</button>
<button
type="button"
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
activeTab === 'competitors' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
onClick={() => setActiveTab('competitors')}
>
<Building2 size={16} />
<span className="px-2 py-0.5 rounded-full bg-accent-amber/15 text-accent-amber text-xs">
{competitors.length}
</span>
</button>
<button
type="button"
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
activeTab === 'whitelist' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
onClick={() => setActiveTab('whitelist')}
>
<Shield size={16} />
<span className="px-2 py-0.5 rounded-full bg-accent-green/15 text-accent-green text-xs">
{whitelist.length}
</span>
</button>
</div>
{/* ==================== 平台规则库 ==================== */}
{activeTab === 'platforms' && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<p className="text-sm text-text-tertiary mt-1">
PDF / Word / ExcelAI
</p>
</CardHeader>
<CardContent>
{loading ? (
<CardSkeleton />
) : (
<div className="space-y-4">
{/* 已有规则列表 */}
{platformRules.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{platformRules.map((rule) => {
const display = getPlatformDisplay(rule.platform)
const status = statusConfig[rule.status] || statusConfig.draft
const StatusIcon = status.icon
return (
<div
key={rule.id}
className="p-4 rounded-xl border border-border-subtle bg-bg-card hover:border-accent-indigo/50 transition-all"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 ${display.color} rounded-xl flex items-center justify-center text-xl`}>
{display.icon}
</div>
<div>
<h3 className="font-medium text-text-primary">{display.name}</h3>
<p className="text-xs text-text-tertiary truncate max-w-[140px]" title={rule.document_name}>
{rule.document_name}
</p>
</div>
</div>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.bg} ${status.color}`}>
<StatusIcon size={12} />
{status.label}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-text-tertiary mb-3">
<span>{rule.parsed_rules?.forbidden_words?.length || 0} </span>
<span>{rule.parsed_rules?.content_requirements?.length || 0} </span>
{rule.parsed_rules?.duration && (
<span> {rule.parsed_rules.duration.min_seconds || '?'}s+</span>
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-border-subtle">
<span className="text-xs text-text-tertiary">
{new Date(rule.updated_at).toLocaleDateString('zh-CN')}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => viewRuleDetail(rule)}
className="p-1.5 rounded-lg text-text-tertiary hover:text-accent-indigo hover:bg-accent-indigo/10 transition-colors"
title={rule.status === 'draft' ? '确认规则' : '查看详情'}
>
{rule.status === 'draft' ? <Edit3 size={16} /> : <Eye size={16} />}
</button>
<button
type="button"
onClick={() => handleDeleteRule(rule.id)}
disabled={submitting}
className="p-1.5 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors disabled:opacity-50"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
})}
{/* 上传新规则按钮 */}
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="p-4 rounded-xl border-2 border-dashed border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-all flex flex-col items-center justify-center gap-2 text-text-tertiary hover:text-accent-indigo min-h-[180px]"
>
<Upload size={24} />
<span className="font-medium"></span>
<span className="text-xs"> PDF / Word / Excel</span>
</button>
</div>
)}
{/* 空状态 */}
{platformRules.length === 0 && (
<div className="text-center py-12">
<FileText size={48} className="mx-auto text-text-tertiary mb-4 opacity-50" />
<h3 className="text-lg font-medium text-text-primary mb-2"></h3>
<p className="text-sm text-text-tertiary mb-6">
AI
</p>
<Button onClick={() => setShowUploadModal(true)}>
<Upload size={16} />
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* ==================== 自定义违禁词 ==================== */}
{activeTab === 'forbidden' && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<p className="text-sm text-text-tertiary mt-1"></p>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<WordsSkeleton />
) : (
<>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索违禁词或分类..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<button
type="button"
onClick={() => setShowAddWordModal(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 border-dashed border-border-subtle hover:border-accent-coral hover:bg-accent-coral/5 transition-all text-text-tertiary hover:text-accent-coral"
>
<Plus size={18} />
<span className="font-medium"></span>
</button>
</div>
{(() => {
const grouped = filteredWords.reduce((acc, word) => {
if (!acc[word.category]) acc[word.category] = []
acc[word.category].push(word)
return acc
}, {} as Record<string, typeof filteredWords>)
return Object.entries(grouped).map(([category, words]) => (
<div key={category} className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-secondary">{category}</span>
<span className="text-xs text-text-tertiary">({words.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{words.map((word) => (
<div key={word.id} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated border border-border-subtle group hover:border-accent-coral/50">
<span className="text-text-primary">{word.word}</span>
<button type="button" onClick={() => handleDeleteWord(word.id)} disabled={submitting} className="text-text-tertiary hover:text-accent-coral transition-colors disabled:opacity-50">
{submitting ? <Loader2 size={14} className="animate-spin" /> : <X size={14} />}
</button>
</div>
))}
</div>
</div>
))
})()}
{filteredWords.length === 0 && (
<div className="text-center py-8 text-text-tertiary">
<Ban size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</>
)}
</CardContent>
</Card>
)}
{/* ==================== 竞品列表 ==================== */}
{activeTab === 'competitors' && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<p className="text-sm text-text-tertiary mt-1"> Logo </p>
</CardHeader>
<CardContent>
{loading ? (
<ListSkeleton count={3} />
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{competitors.map((competitor) => (
<div key={competitor.id} className="p-4 rounded-xl bg-bg-elevated border border-border-subtle flex items-center justify-between group hover:border-accent-amber/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent-amber/15 rounded-lg flex items-center justify-center">
<Building2 size={20} className="text-accent-amber" />
</div>
<span className="font-medium text-text-primary">{competitor.name}</span>
</div>
<button type="button" onClick={() => handleDeleteCompetitor(competitor.id)} disabled={submitting} className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors disabled:opacity-50">
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
))}
<button type="button" onClick={() => setShowAddCompetitorModal(true)} className="p-4 rounded-xl border-2 border-dashed border-border-subtle hover:border-accent-amber hover:bg-accent-amber/5 transition-all flex items-center justify-center gap-2 text-text-tertiary hover:text-accent-amber">
<Plus size={20} />
<span className="font-medium"></span>
</button>
</div>
)}
</CardContent>
</Card>
)}
{/* ==================== 白名单 ==================== */}
{activeTab === 'whitelist' && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<p className="text-sm text-text-tertiary mt-1">使</p>
</CardHeader>
<CardContent>
{loading ? (
<ListSkeleton count={2} />
) : (
<div className="space-y-3">
{whitelist.map((item) => (
<div key={item.id} className="flex items-center justify-between p-4 rounded-xl bg-bg-elevated border border-border-subtle hover:border-accent-green/50">
<div>
<p className="font-medium text-text-primary">{item.term}</p>
<p className="text-sm text-text-tertiary mt-0.5">{item.reason}</p>
</div>
<button type="button" onClick={() => handleDeleteWhitelist(item.id)} disabled={submitting} className="p-2 rounded-lg text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 transition-colors disabled:opacity-50">
{submitting ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
))}
<button type="button" onClick={() => setShowAddWhitelistModal(true)} className="w-full p-4 rounded-xl border-2 border-dashed border-border-subtle hover:border-accent-green hover:bg-accent-green/5 transition-all flex items-center justify-center gap-2 text-text-tertiary hover:text-accent-green">
<Plus size={20} />
<span className="font-medium"></span>
</button>
</div>
)}
</CardContent>
</Card>
)}
{/* ==================== 上传规则文档弹窗 ==================== */}
<Modal
isOpen={showUploadModal}
onClose={() => { if (!parsing) { setShowUploadModal(false); setUploadPlatform(''); setUploadFile(null) } }}
title="上传平台规则文档"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<select
value={uploadPlatform}
onChange={(e) => setUploadPlatform(e.target.value)}
disabled={parsing}
className="w-full px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo disabled:opacity-50"
>
<option value=""></option>
<option value="douyin"></option>
<option value="xiaohongshu"></option>
<option value="bilibili">B站</option>
<option value="kuaishou"></option>
<option value="weibo"></option>
<option value="wechat"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setUploadFile(file)
}}
/>
<div
onClick={() => !parsing && fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer.files?.[0]
if (file) setUploadFile(file)
}}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer ${
uploadFile ? 'border-accent-green bg-accent-green/5' : 'border-border-subtle hover:border-accent-indigo'
} ${parsing ? 'opacity-50 pointer-events-none' : ''}`}
>
{uploadFile ? (
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-green" />
<div className="text-left">
<p className="text-sm font-medium text-text-primary">{uploadFile.name}</p>
<p className="text-xs text-text-tertiary">{(uploadFile.size / 1024).toFixed(1)} KB</p>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setUploadFile(null) }}
className="p-1 rounded-lg text-text-tertiary hover:text-accent-coral"
>
<X size={16} />
</button>
</div>
) : (
<>
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-sm text-text-primary mb-1"></p>
<p className="text-xs text-text-tertiary"> PDF / Word / Excel / TXT </p>
</>
)}
</div>
</div>
<div className="p-4 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20">
<h4 className="text-sm font-medium text-accent-indigo mb-2">AI </h4>
<ul className="text-xs text-text-secondary space-y-1">
<li>AI </li>
<li></li>
<li></li>
</ul>
</div>
{parsing && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-bg-elevated">
<Loader2 size={20} className="animate-spin text-accent-indigo" />
<div className="flex-1">
<p className="text-sm font-medium text-text-primary">
{isOssUploading ? '正在上传文档...' : 'AI 正在解析规则...'}
</p>
<p className="text-xs text-text-tertiary mt-0.5">
{isOssUploading ? `上传进度 ${ossProgress}%` : '这可能需要几秒钟'}
</p>
</div>
</div>
)}
<div className="flex gap-3 justify-end pt-2">
<Button variant="ghost" onClick={() => { setShowUploadModal(false); setUploadPlatform(''); setUploadFile(null) }} disabled={parsing}>
</Button>
<Button onClick={handleUploadAndParse} disabled={!uploadPlatform || !uploadFile || parsing} loading={parsing}>
</Button>
</div>
</div>
</Modal>
{/* ==================== 规则详情/编辑弹窗 ==================== */}
<Modal
isOpen={showDetailModal}
onClose={() => { setShowDetailModal(false); setSelectedRule(null); setEditingRules(null); setEditingForbiddenInput('') }}
title={selectedRule ? `${getPlatformDisplay(selectedRule.platform).name} 平台规则` : '规则详情'}
size="lg"
>
{selectedRule && editingRules && (
<div className="space-y-5">
{/* 头部信息 */}
<div className="flex items-center gap-4 p-4 rounded-xl bg-bg-elevated">
<div className={`w-12 h-12 ${getPlatformDisplay(selectedRule.platform).color} rounded-xl flex items-center justify-center text-2xl`}>
{getPlatformDisplay(selectedRule.platform).icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-text-primary">{getPlatformDisplay(selectedRule.platform).name}</h3>
<p className="text-xs text-text-tertiary truncate">{selectedRule.document_name}</p>
</div>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig[selectedRule.status]?.bg} ${statusConfig[selectedRule.status]?.color}`}>
{statusConfig[selectedRule.status]?.label}
</span>
</div>
{/* 违禁词 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2">
<span className="text-text-tertiary font-normal ml-1">({editingRules.forbidden_words.length})</span>
</h4>
<div className="flex flex-wrap gap-2 mb-2">
{editingRules.forbidden_words.map((word, i) => (
<span key={i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-accent-coral/10 text-accent-coral text-sm border border-accent-coral/20">
{word}
{selectedRule.status === 'draft' && (
<button type="button" onClick={() => removeForbiddenWord(i)} className="hover:text-accent-coral/70">
<X size={12} />
</button>
)}
</span>
))}
</div>
{selectedRule.status === 'draft' && (
<div className="flex gap-2">
<input
type="text"
value={editingForbiddenInput}
onChange={(e) => setEditingForbiddenInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord() } }}
placeholder="添加违禁词..."
className="flex-1 px-3 py-1.5 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
<Button size="sm" onClick={addForbiddenWord} disabled={!editingForbiddenInput.trim()}>
</Button>
</div>
)}
</div>
{/* 限制词 */}
{editingRules.restricted_words.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="space-y-2">
{editingRules.restricted_words.map((rw, i) => (
<div key={i} className="p-3 rounded-lg bg-bg-elevated border border-border-subtle">
<p className="text-sm text-text-primary font-medium">{rw.word}</p>
<p className="text-xs text-text-tertiary mt-0.5">{rw.condition}</p>
<p className="text-xs text-accent-indigo mt-0.5">{rw.suggestion}</p>
</div>
))}
</div>
</div>
)}
{/* 时长要求 */}
{editingRules.duration && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="p-3 rounded-lg bg-bg-elevated border border-border-subtle text-sm text-text-secondary">
{editingRules.duration.min_seconds && <span> {editingRules.duration.min_seconds} </span>}
{editingRules.duration.min_seconds && editingRules.duration.max_seconds && <span> / </span>}
{editingRules.duration.max_seconds && <span> {editingRules.duration.max_seconds} </span>}
</div>
</div>
)}
{/* 内容要求 */}
{editingRules.content_requirements.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="space-y-1.5">
{editingRules.content_requirements.map((req, i) => (
<div key={i} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-elevated border border-border-subtle">
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
<span className="text-sm text-text-primary flex-1">{req}</span>
{selectedRule.status === 'draft' && (
<button type="button" onClick={() => removeContentReq(i)} className="text-text-tertiary hover:text-accent-coral">
<X size={14} />
</button>
)}
</div>
))}
</div>
</div>
)}
{/* 其他规则 */}
{editingRules.other_rules.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="space-y-2">
{editingRules.other_rules.map((or, i) => (
<div key={i} className="p-3 rounded-lg bg-bg-elevated border border-border-subtle">
<p className="text-sm font-medium text-text-primary">{or.rule}</p>
<p className="text-xs text-text-tertiary mt-0.5">{or.description}</p>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-3 justify-end pt-2 border-t border-border-subtle">
<Button variant="ghost" onClick={() => { setShowDetailModal(false); setSelectedRule(null); setEditingRules(null) }}>
{selectedRule.status === 'draft' ? '稍后确认' : '关闭'}
</Button>
{selectedRule.status === 'draft' && (
<Button onClick={handleConfirmRule} disabled={submitting} loading={submitting}>
<CheckCircle size={16} />
</Button>
)}
{selectedRule.status === 'active' && (
<Button
variant="secondary"
onClick={() => {
setShowDetailModal(false)
setShowUploadModal(true)
setUploadPlatform(selectedRule.platform)
}}
>
<Upload size={16} />
</Button>
)}
</div>
</div>
)}
</Modal>
{/* ==================== 添加违禁词弹窗 ==================== */}
<Modal
isOpen={showAddWordModal}
onClose={() => { setShowAddWordModal(false); setNewWord(''); setBatchWords('') }}
title="添加违禁词"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<select value={newCategory} onChange={(e) => setNewCategory(e.target.value)} className="w-full px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo">
{categoryOptions.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="flex gap-2">
<input type="text" value={newWord} onChange={(e) => setNewWord(e.target.value)} placeholder="输入违禁词" className="flex-1 px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" />
<Button onClick={handleAddWord} disabled={!newWord.trim() || submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : '添加'}
</Button>
</div>
</div>
<div className="relative">
<div className="absolute inset-x-0 top-1/2 border-t border-border-subtle" />
<div className="relative flex justify-center">
<span className="bg-bg-card px-3 text-sm text-text-tertiary"></span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<Upload size={14} className="inline mr-1" />
</label>
<textarea value={batchWords} onChange={(e) => setBatchWords(e.target.value)} placeholder={'最好\n第一\n最佳\n...'} className="w-full h-32 px-4 py-3 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo font-mono text-sm" />
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-text-tertiary">{batchWords.split('\n').filter(w => w.trim()).length} </span>
<Button onClick={handleBatchAdd} disabled={!batchWords.trim() || submitting}>
{submitting ? (<span className="flex items-center gap-2"><Loader2 size={14} className="animate-spin" />...</span>) : '批量添加'}
</Button>
</div>
</div>
</div>
</Modal>
{/* ==================== 添加竞品弹窗 ==================== */}
<Modal isOpen={showAddCompetitorModal} onClose={() => { setShowAddCompetitorModal(false); setNewCompetitor('') }} title="添加竞品">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<input type="text" value={newCompetitor} onChange={(e) => setNewCompetitor(e.target.value)} placeholder="输入竞品品牌名称" className="w-full px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" />
</div>
<p className="text-sm text-text-tertiary">AI将在视频中自动检测该品牌的Logo或名称出现</p>
<div className="flex gap-3 justify-end pt-2">
<Button variant="ghost" onClick={() => { setShowAddCompetitorModal(false); setNewCompetitor('') }}></Button>
<Button onClick={handleAddCompetitor} disabled={!newCompetitor.trim() || submitting}>
{submitting ? (<span className="flex items-center gap-2"><Loader2 size={14} className="animate-spin" />...</span>) : '添加'}
</Button>
</div>
</div>
</Modal>
{/* ==================== 添加白名单弹窗 ==================== */}
<Modal isOpen={showAddWhitelistModal} onClose={() => { setShowAddWhitelistModal(false); setNewWhitelistTerm(''); setNewWhitelistReason('') }} title="添加白名单">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<input type="text" value={newWhitelistTerm} onChange={(e) => setNewWhitelistTerm(e.target.value)} placeholder="输入需要豁免的词汇" className="w-full px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" />
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<input type="text" value={newWhitelistReason} onChange={(e) => setNewWhitelistReason(e.target.value)} placeholder="例如:品牌授权使用" className="w-full px-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo" />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button variant="ghost" onClick={() => { setShowAddWhitelistModal(false); setNewWhitelistTerm(''); setNewWhitelistReason('') }}></Button>
<Button onClick={handleAddWhitelist} disabled={!newWhitelistTerm.trim() || submitting}>
{submitting ? (<span className="flex items-center gap-2"><Loader2 size={14} className="animate-spin" />...</span>) : '添加'}
</Button>
</div>
</div>
</Modal>
</div>
)
}