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:
co-authored by
Claude Opus 4.6
parent
c17c64cd11
commit
2f24dcfd34
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user