feat: 规则冲突检测增强 — 后端接入 DB 规则 + 前端集成检查按钮

后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则,
新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面
添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-10 14:12:49 +08:00
co-authored by Claude Opus 4.6
parent c17c64cd11
commit 2f24dcfd34
4 changed files with 580 additions and 37 deletions
+162 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@@ -27,11 +27,13 @@ import {
Upload,
Trash2,
File,
Loader2
Loader2,
Search
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
import type { ProjectResponse } from '@/types/project'
@@ -181,6 +183,7 @@ export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
// 加载状态
@@ -205,6 +208,81 @@ export default function BriefConfigPage() {
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [ruleConflicts, setRuleConflicts] = useState<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(null)
const platformSelectOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'bilibili', label: 'B站' },
]
// 点击外部关闭平台选择下拉
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
setShowPlatformSelect(false)
}
}
if (showPlatformSelect) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setRuleConflicts([
{
brief_rule: '卖点包含:100%纯天然成分',
platform_rule: `${platform} 禁止使用:100%`,
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
},
{
brief_rule: 'Brief 最长时长:5秒',
platform_rule: `${platform} 最短要求:7秒`,
suggestion: 'Brief 最长 5s 低于平台最短要求 7s,视频可能不达标',
},
])
setShowConflictModal(true)
setIsCheckingConflicts(false)
return
}
try {
// 代理商角色可能没有 brand_id,从 brandBrief 取关联品牌的 ID
const brandId = user?.brand_id || brandBrief.id || ''
const briefRules: Record<string, unknown> = {
selling_points: agencyConfig.sellingPoints.map(sp => sp.content),
}
const result = await api.validateRules({
brand_id: brandId,
platform,
brief_rules: briefRules,
})
setRuleConflicts(result.conflicts)
if (result.conflicts.length > 0) {
setShowConflictModal(true)
} else {
toast.success('未发现规则冲突')
}
} catch (err) {
console.error('规则冲突检测失败:', err)
toast.error('规则冲突检测失败')
} finally {
setIsCheckingConflicts(false)
}
}
// 加载数据
const loadData = useCallback(async () => {
if (USE_MOCK) {
@@ -470,6 +548,39 @@ export default function BriefConfigPage() {
{brandBrief.brandName}
</p>
</div>
<div className="relative" ref={platformDropdownRef}>
<Button
variant="secondary"
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
disabled={isCheckingConflicts}
>
{isCheckingConflicts ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Search size={16} />
</>
)}
</Button>
{showPlatformSelect && (
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
{platformSelectOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleCheckConflicts(opt.value)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
<Button variant="secondary" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={16} />
{isExporting ? '导出中...' : '导出规则'}
@@ -1024,6 +1135,54 @@ export default function BriefConfigPage() {
</div>
</div>
</Modal>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{ruleConflicts.length === 0 ? (
<div className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
<p className="text-text-primary font-medium"></p>
<p className="text-sm text-text-secondary mt-1">Brief </p>
</div>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
<p className="text-sm text-accent-amber">
{ruleConflicts.length}
</p>
</div>
{ruleConflicts.map((conflict, index) => (
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
</div>
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
</div>
</div>
))}
</>
)}
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
</Button>
</div>
</div>
</Modal>
</div>
)
}
+179 -16
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
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'
@@ -20,10 +20,13 @@ import {
Upload,
ChevronDown,
ChevronUp,
Loader2
Loader2,
Search
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
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'
@@ -109,6 +112,7 @@ 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')
@@ -133,6 +137,82 @@ export default function ProjectConfigPage() {
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('')
@@ -336,19 +416,54 @@ export default function ProjectConfigPage() {
</p>
</div>
</div>
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
<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配置 */}
@@ -757,6 +872,54 @@ export default function ProjectConfigPage() {
</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>
)
}