diff --git a/backend/app/schemas/review.py b/backend/app/schemas/review.py index d6fe657..4ca7294 100644 --- a/backend/app/schemas/review.py +++ b/backend/app/schemas/review.py @@ -109,6 +109,7 @@ class ScriptReviewRequest(BaseModel): platform: Platform = Field(..., description="投放平台") brand_id: str = Field(..., description="品牌 ID") required_points: Optional[list[str]] = Field(None, description="必要卖点列表") + blacklist_words: Optional[list[dict]] = Field(None, description="Brief 黑名单词 [{word, reason}]") soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文") diff --git a/frontend/app/brand/rules/page.tsx b/frontend/app/brand/rules/page.tsx index abebf21..2acf0ef 100644 --- a/frontend/app/brand/rules/page.tsx +++ b/frontend/app/brand/rules/page.tsx @@ -1,82 +1,100 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { Plus, Shield, Ban, Building2, Search, X, Upload, Trash2, FileText, Download, Eye, Loader2 } from 'lucide-react' +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' +import { useOSSUpload } from '@/hooks/useOSSUpload' import type { ForbiddenWordResponse, CompetitorResponse, WhitelistResponse, - PlatformRuleResponse, + BrandPlatformRuleResponse, + ParsedRulesData, } from '@/types/rules' -// ===== 平台规则库 mock 数据 (USE_MOCK 模式) ===== +// ===== 平台图标映射 ===== -interface PlatformRuleDisplay { - id: string - name: string - icon: string - color: string - rules: { forbiddenWords: number; competitors: number; whitelist: number } - version: string - updatedAt: string +const platformDisplayMap: Record = { + 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: '微信视频号' }, } -const platformRuleLibraries: PlatformRuleDisplay[] = [ +function getPlatformDisplay(platform: string) { + return platformDisplayMap[platform] || { icon: '📋', color: 'bg-gray-400', name: platform } +} + +type IconComponent = typeof CheckCircle +const statusConfig: Record = { + 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: 'douyin', - name: '抖音', - icon: '🎵', - color: 'bg-[#25F4EE]', - rules: { forbiddenWords: 156, competitors: 0, whitelist: 12 }, - version: 'v2024.02', - updatedAt: '2024-02-01', + 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: 'xiaohongshu', - name: '小红书', - icon: '📕', - color: 'bg-[#fe2c55]', - rules: { forbiddenWords: 142, competitors: 0, whitelist: 8 }, - version: 'v2024.01', - updatedAt: '2024-01-20', + 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: 'bilibili', - name: 'B站', - icon: '📺', - color: 'bg-[#00a1d6]', - rules: { forbiddenWords: 98, competitors: 0, whitelist: 15 }, - version: 'v2024.02', - updatedAt: '2024-02-03', - }, - { - id: 'kuaishou', - name: '快手', - icon: '⚡', - color: 'bg-[#ff4906]', - rules: { forbiddenWords: 134, competitors: 0, whitelist: 10 }, - version: 'v2024.01', - updatedAt: '2024-01-15', - }, - { - id: 'weibo', - name: '微博', - icon: '🔴', - color: 'bg-[#e6162d]', - rules: { forbiddenWords: 89, competitors: 0, whitelist: 6 }, - version: 'v2023.12', - updatedAt: '2023-12-20', + 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', }, ] -// ===== Mock 数据 (USE_MOCK 模式) ===== - const mockForbiddenWords: ForbiddenWordResponse[] = [ { id: '1', word: '最好', category: '极限词', severity: 'high' }, { id: '2', word: '第一', category: '极限词', severity: 'high' }, @@ -99,17 +117,6 @@ const mockWhitelist: WhitelistResponse[] = [ { id: '2', term: '特定产品名', reason: '官方产品名称', brand_id: '' }, ] -// ===== 平台图标映射 (用于 API 模式下的平台展示) ===== - -const platformDisplayMap: Record = { - 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: '微信视频号' }, -} - const categoryOptions = [ { value: '极限词', label: '极限词' }, { value: '虚假宣称', label: '虚假宣称' }, @@ -118,29 +125,6 @@ const categoryOptions = [ { value: '自定义', label: '自定义' }, ] -// ===== 将 PlatformRuleResponse 转换为 PlatformRuleDisplay ===== - -function toPlatformDisplay(rule: PlatformRuleResponse): PlatformRuleDisplay { - const display = platformDisplayMap[rule.platform] || { - icon: '📋', - color: 'bg-gray-400', - name: rule.platform, - } - return { - id: rule.platform, - name: display.name, - icon: display.icon, - color: display.color, - rules: { - forbiddenWords: Array.isArray(rule.rules) ? rule.rules.length : 0, - competitors: 0, - whitelist: 0, - }, - version: rule.version, - updatedAt: rule.updated_at, - } -} - // ===== Loading Skeleton 组件 ===== function CardSkeleton() { @@ -207,6 +191,8 @@ function ListSkeleton({ count = 3 }: { count?: number }) { export default function RulesPage() { const toast = useToast() + const fileInputRef = useRef(null) + const { upload: ossUpload, isUploading: isOssUploading, progress: ossProgress } = useOSSUpload('rules') // Tab 选择 const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms') @@ -216,28 +202,23 @@ export default function RulesPage() { const [forbiddenWords, setForbiddenWords] = useState([]) const [competitors, setCompetitors] = useState([]) const [whitelist, setWhitelist] = useState([]) - const [platforms, setPlatforms] = useState([]) + const [platformRules, setPlatformRules] = useState([]) // 加载状态 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(null) - // 重新上传规则库 - const [showReuploadModal, setShowReuploadModal] = useState(false) - const [reuploadPlatform, setReuploadPlatform] = useState(null) - - // 下载确认 - const [showDownloadModal, setShowDownloadModal] = useState(false) - const [downloadPlatform, setDownloadPlatform] = useState(null) - - // 查看规则库详情 + // 查看/编辑解析结果 const [showDetailModal, setShowDetailModal] = useState(false) - const [selectedPlatform, setSelectedPlatform] = useState(null) + const [selectedRule, setSelectedRule] = useState(null) + const [editingRules, setEditingRules] = useState(null) + const [editingForbiddenInput, setEditingForbiddenInput] = useState('') // 添加违禁词 const [showAddWordModal, setShowAddWordModal] = useState(false) @@ -257,10 +238,7 @@ export default function RulesPage() { // ===== 数据加载 ===== const loadForbiddenWords = useCallback(async () => { - if (USE_MOCK) { - setForbiddenWords(mockForbiddenWords) - return - } + if (USE_MOCK) { setForbiddenWords(mockForbiddenWords); return } try { const res = await api.listForbiddenWords() setForbiddenWords(res.items) @@ -270,10 +248,7 @@ export default function RulesPage() { }, [toast]) const loadCompetitors = useCallback(async () => { - if (USE_MOCK) { - setCompetitors(mockCompetitors) - return - } + if (USE_MOCK) { setCompetitors(mockCompetitors); return } try { const res = await api.listCompetitors() setCompetitors(res.items) @@ -283,10 +258,7 @@ export default function RulesPage() { }, [toast]) const loadWhitelist = useCallback(async () => { - if (USE_MOCK) { - setWhitelist(mockWhitelist) - return - } + if (USE_MOCK) { setWhitelist(mockWhitelist); return } try { const res = await api.listWhitelist() setWhitelist(res.items) @@ -296,13 +268,10 @@ export default function RulesPage() { }, [toast]) const loadPlatformRules = useCallback(async () => { - if (USE_MOCK) { - setPlatforms(platformRuleLibraries) - return - } + if (USE_MOCK) { setPlatformRules(mockPlatformRules); return } try { - const res = await api.listPlatformRules() - setPlatforms(res.items.map(toPlatformDisplay)) + const res = await api.listBrandPlatformRules() + setPlatformRules(res.items) } catch (err) { toast.error('加载平台规则失败:' + (err instanceof Error ? err.message : '未知错误')) } @@ -310,18 +279,11 @@ export default function RulesPage() { const loadAllData = useCallback(async () => { setLoading(true) - await Promise.all([ - loadForbiddenWords(), - loadCompetitors(), - loadWhitelist(), - loadPlatformRules(), - ]) + await Promise.all([loadForbiddenWords(), loadCompetitors(), loadWhitelist(), loadPlatformRules()]) setLoading(false) }, [loadForbiddenWords, loadCompetitors, loadWhitelist, loadPlatformRules]) - useEffect(() => { - loadAllData() - }, [loadAllData]) + useEffect(() => { loadAllData() }, [loadAllData]) // ===== 过滤违禁词 ===== @@ -331,21 +293,165 @@ export default function RulesPage() { w.category.toLowerCase().includes(searchQuery.toLowerCase()) ) - // ===== 平台操作 ===== + // ===== 平台规则操作 ===== - const viewPlatformDetail = (platform: PlatformRuleDisplay) => { - setSelectedPlatform(platform) + 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 + const uploadResult = await ossUpload(uploadFile) + 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) + } + } + + 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 handleReupload = (platform: PlatformRuleDisplay) => { - setReuploadPlatform(platform) - setShowReuploadModal(true) + // ===== 编辑解析结果辅助 ===== + + const addForbiddenWord = () => { + if (!editingForbiddenInput.trim() || !editingRules) return + setEditingRules({ + ...editingRules, + forbidden_words: [...editingRules.forbidden_words, editingForbiddenInput.trim()], + }) + setEditingForbiddenInput('') } - const handleDownload = (platform: PlatformRuleDisplay) => { - setDownloadPlatform(platform) - setShowDownloadModal(true) + 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), + }) } // ===== 违禁词操作 ===== @@ -355,13 +461,7 @@ export default function RulesPage() { setSubmitting(true) try { if (USE_MOCK) { - const newItem: ForbiddenWordResponse = { - id: Date.now().toString(), - word: newWord.trim(), - category: newCategory, - severity: 'medium', - } - setForbiddenWords(prev => [...prev, newItem]) + 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() @@ -382,12 +482,7 @@ export default function RulesPage() { setSubmitting(true) try { if (USE_MOCK) { - const newWords: ForbiddenWordResponse[] = words.map((word, i) => ({ - id: `${Date.now()}-${i}`, - word: word.trim(), - category: newCategory, - severity: 'medium', - })) + 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) { @@ -408,12 +503,8 @@ export default function RulesPage() { 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() - } + 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 : '未知错误')) @@ -429,13 +520,7 @@ export default function RulesPage() { setSubmitting(true) try { if (USE_MOCK) { - const newItem: CompetitorResponse = { - id: Date.now().toString(), - name: newCompetitor.trim(), - brand_id: '', - keywords: [newCompetitor.trim()], - } - setCompetitors(prev => [...prev, newItem]) + 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() @@ -453,12 +538,8 @@ export default function RulesPage() { 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() - } + 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 : '未知错误')) @@ -474,13 +555,7 @@ export default function RulesPage() { setSubmitting(true) try { if (USE_MOCK) { - const newItem: WhitelistResponse = { - id: Date.now().toString(), - term: newWhitelistTerm.trim(), - reason: newWhitelistReason.trim(), - brand_id: '', - } - setWhitelist(prev => [...prev, newItem]) + 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() @@ -499,12 +574,8 @@ export default function RulesPage() { const handleDeleteWhitelist = async (id: string) => { setSubmitting(true) try { - if (USE_MOCK) { - setWhitelist(prev => prev.filter(w => w.id !== id)) - } else { - // 白名单目前没有 delete API,本地移除 - setWhitelist(prev => prev.filter(w => w.id !== id)) - } + 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 : '未知错误')) @@ -525,24 +596,20 @@ export default function RulesPage() { - {/* 平台规则库 */} + {/* ==================== 平台规则库 ==================== */} {activeTab === 'platforms' && ( 平台规则库

- 管理各平台的审核规则库,启用后将应用于对应平台的视频审核 + 上传各平台的规则文档(PDF / Word / Excel),AI 自动解析提取合规规则,确认后应用于审核

{loading ? ( ) : ( -
- {platforms.map((platform) => ( -
-
-
- {platform.icon} -
-
-

{platform.name}

-

{platform.version}

-
-
+
+ {/* 已有规则列表 */} + {platformRules.length > 0 && ( +
+ {platformRules.map((rule) => { + const display = getPlatformDisplay(rule.platform) + const status = statusConfig[rule.status] || statusConfig.draft + const StatusIcon = status.icon + return ( +
+
+
+
+ {display.icon} +
+
+

{display.name}

+

+ {rule.document_name} +

+
+
+ + + {status.label} + +
-
- {platform.rules.forbiddenWords} 违禁词 - {platform.rules.whitelist} 白名单 -
+
+ {rule.parsed_rules?.forbidden_words?.length || 0} 违禁词 + {rule.parsed_rules?.content_requirements?.length || 0} 内容要求 + {rule.parsed_rules?.duration && ( + 时长 {rule.parsed_rules.duration.min_seconds || '?'}s+ + )} +
-
- 更新于 {platform.updatedAt} -
- - - -
-
+
+ + {new Date(rule.updated_at).toLocaleDateString('zh-CN')} + +
+ + +
+
+
+ ) + })} + + {/* 上传新规则按钮 */} +
- ))} + )} - {/* 上传规则库 */} - + {/* 空状态 */} + {platformRules.length === 0 && ( +
+ +

暂无平台规则

+

+ 上传平台规则文档,AI 将自动提取违禁词、内容要求等合规规则 +

+ +
+ )}
)} )} - {/* 自定义违禁词列表 */} + {/* ==================== 自定义违禁词 ==================== */} {activeTab === 'forbidden' && ( @@ -678,7 +772,6 @@ export default function RulesPage() { ) : ( <> - {/* 搜索框和添加按钮 */}
@@ -700,7 +793,6 @@ export default function RulesPage() {
- {/* 按分类分组显示 */} {(() => { const grouped = filteredWords.reduce((acc, word) => { if (!acc[word.category]) acc[word.category] = [] @@ -716,17 +808,9 @@ export default function RulesPage() {
{words.map((word) => ( -
+
{word.word} -
@@ -748,7 +832,7 @@ export default function RulesPage() { )} - {/* 竞品列表 */} + {/* ==================== 竞品列表 ==================== */} {activeTab === 'competitors' && ( @@ -768,23 +852,12 @@ export default function RulesPage() {
{competitor.name}
-
))} - - {/* 添加竞品按钮 */} - @@ -794,7 +867,7 @@ export default function RulesPage() { )} - {/* 白名单 */} + {/* ==================== 白名单 ==================== */} {activeTab === 'whitelist' && ( @@ -812,23 +885,12 @@ export default function RulesPage() {

{item.term}

{item.reason}

- ))} - - {/* 添加白名单按钮 */} - @@ -838,11 +900,11 @@ export default function RulesPage() {
)} - {/* 上传规则库弹窗 */} + {/* ==================== 上传规则文档弹窗 ==================== */} { setShowUploadModal(false); setUploadPlatform(''); setUploadFile(null); }} - title="上传平台规则库" + onClose={() => { if (!parsing) { setShowUploadModal(false); setUploadPlatform(''); setUploadFile(null) } }} + title="上传平台规则文档" >
@@ -850,7 +912,8 @@ export default function RulesPage() {
-
- -

点击或拖拽上传文件

-

支持 JSON / Excel / Word / PDF 格式

+ { + const file = e.target.files?.[0] + if (file) setUploadFile(file) + }} + /> +
!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 ? ( +
+ +
+

{uploadFile.name}

+

{(uploadFile.size / 1024).toFixed(1)} KB

+
+ +
+ ) : ( + <> + +

点击或拖拽上传文件

+

支持 PDF / Word / Excel / TXT 格式

+ + )}
-

文件格式说明

+

AI 智能解析

    -
  • JSON 格式:包含 forbiddenWords、whitelist 字段的对象
  • -
  • Excel 格式:第一列为词汇,第二列为分类(可选)
  • -
  • Word / PDF:AI 将自动识别并提取规则内容
  • +
  • AI 将自动从文档中提取违禁词、内容要求、时长规则等
  • +
  • 解析完成后可编辑调整,确认后即生效
  • +
  • 同一平台的新规则生效后,旧规则自动停用
+ {parsing && ( +
+ +
+

+ {isOssUploading ? '正在上传文档...' : 'AI 正在解析规则...'} +

+

+ {isOssUploading ? `上传进度 ${ossProgress}%` : '这可能需要几秒钟'} +

+
+
+ )} +
- -
- {/* 查看规则库详情弹窗 */} + {/* ==================== 规则详情/编辑弹窗 ==================== */} { setShowDetailModal(false); setSelectedPlatform(null); }} - title={selectedPlatform ? `${selectedPlatform.name}规则库详情` : '规则库详情'} + onClose={() => { setShowDetailModal(false); setSelectedRule(null); setEditingRules(null); setEditingForbiddenInput('') }} + title={selectedRule ? `${getPlatformDisplay(selectedRule.platform).name} 平台规则` : '规则详情'} size="lg" > - {selectedPlatform && ( -
+ {selectedRule && editingRules && ( +
+ {/* 头部信息 */}
-
- {selectedPlatform.icon} +
+ {getPlatformDisplay(selectedRule.platform).icon}
-
-

{selectedPlatform.name}

-
- 版本:{selectedPlatform.version} - 更新时间:{selectedPlatform.updatedAt} -
-
-
- -
-
-

{selectedPlatform.rules.forbiddenWords}

-

违禁词

-
-
-

{selectedPlatform.rules.whitelist}

-

白名单

-
-
-

12

-

规则分类

+
+

{getPlatformDisplay(selectedRule.platform).name}

+

{selectedRule.document_name}

+ + {statusConfig[selectedRule.status]?.label} +
+ {/* 违禁词 */}
-

规则分类概览

-
- {[ - { name: '极限词', count: 45 }, - { name: '虚假宣传', count: 38 }, - { name: '敏感词汇', count: 32 }, - { name: '价格欺诈', count: 21 }, - { name: '其他', count: 20 }, - ].map((cat) => ( -
- {cat.name} - {cat.count} 条 -
+

+ 违禁词 + ({editingRules.forbidden_words.length}) +

+
+ {editingRules.forbidden_words.map((word, i) => ( + + {word} + {selectedRule.status === 'draft' && ( + + )} + ))}
+ {selectedRule.status === 'draft' && ( +
+ 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" + /> + +
+ )}
-
- - - -
-
- )} - - - {/* 重新上传规则库弹窗 */} - { setShowReuploadModal(false); setReuploadPlatform(null); }} - title={reuploadPlatform ? `重新上传${reuploadPlatform.name}规则库` : '重新上传规则库'} - > - {reuploadPlatform && ( -
-
-
- {reuploadPlatform.icon} -
+ {/* 限制词 */} + {editingRules.restricted_words.length > 0 && (
-

{reuploadPlatform.name}

-

当前版本:{reuploadPlatform.version}

+

限制词

+
+ {editingRules.restricted_words.map((rw, i) => ( +
+

{rw.word}

+

条件:{rw.condition}

+

建议:{rw.suggestion}

+
+ ))} +
-
+ )} -
-

注意

-

重新上传将覆盖当前规则库,此操作不可撤销

-
- -
- -
- -

点击或拖拽上传文件

-

支持 JSON / Excel / Word / PDF 格式

-
-

Word 和 PDF 文件将由 AI 自动提取规则内容

-
- -
- - -
-
- )} -
- - {/* 下载规则库弹窗 */} - { setShowDownloadModal(false); setDownloadPlatform(null); }} - title={downloadPlatform ? `下载${downloadPlatform.name}规则库` : '下载规则库'} - > - {downloadPlatform && ( -
-
-
- {downloadPlatform.icon} -
+ {/* 时长要求 */} + {editingRules.duration && (
-

{downloadPlatform.name}

-

版本:{downloadPlatform.version} · 更新于 {downloadPlatform.updatedAt}

+

时长要求

+
+ {editingRules.duration.min_seconds && 最短 {editingRules.duration.min_seconds} 秒} + {editingRules.duration.min_seconds && editingRules.duration.max_seconds && / } + {editingRules.duration.max_seconds && 最长 {editingRules.duration.max_seconds} 秒} +
-
+ )} -
- -
- + )}
-
-

JSON

-

程序导入

-
-
- - - - + ))} +
-
+ )} -
-
- 违禁词数量 - {downloadPlatform.rules.forbiddenWords} 条 + {/* 其他规则 */} + {editingRules.other_rules.length > 0 && ( +
+

其他规则

+
+ {editingRules.other_rules.map((or, i) => ( +
+

{or.rule}

+

{or.description}

+
+ ))} +
-
- 白名单数量 - {downloadPlatform.rules.whitelist} 条 -
-
+ )} -
- - + {selectedRule.status === 'draft' && ( + + )} + {selectedRule.status === 'active' && ( + + )}
)} - {/* 添加违禁词弹窗 */} + {/* ==================== 添加违禁词弹窗 ==================== */} { setShowAddWordModal(false); setNewWord(''); setBatchWords(''); }} + onClose={() => { setShowAddWordModal(false); setNewWord(''); setBatchWords('') }} title="添加违禁词" >
- 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 => ())}
-
- 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" - /> + 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" />
-
-
-