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:
parent
c17c64cd11
commit
2f24dcfd34
@ -455,30 +455,67 @@ async def get_platform_rules(platform: str) -> PlatformRuleResponse:
|
||||
# ==================== 规则冲突检测 ====================
|
||||
|
||||
@router.post("/validate", response_model=RuleValidateResponse)
|
||||
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
async def validate_rules(
|
||||
request: RuleValidateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RuleValidateResponse:
|
||||
"""检测 Brief 与平台规则冲突(合并 DB 规则 + 硬编码兜底)"""
|
||||
conflicts = []
|
||||
|
||||
platform_rule = _platform_rules.get(request.platform)
|
||||
if not platform_rule:
|
||||
return RuleValidateResponse(conflicts=[])
|
||||
# 1. 收集违禁词:DB active 规则优先,硬编码兜底
|
||||
db_rules = await get_active_platform_rules(
|
||||
x_tenant_id, request.brand_id, request.platform, db
|
||||
)
|
||||
forbidden_words: set[str] = set()
|
||||
min_seconds: Optional[int] = None
|
||||
max_seconds: Optional[int] = None
|
||||
|
||||
# 检查 required_phrases 是否包含违禁词
|
||||
required_phrases = request.brief_rules.get("required_phrases", [])
|
||||
platform_forbidden = []
|
||||
for rule in platform_rule.get("rules", []):
|
||||
if db_rules:
|
||||
forbidden_words.update(db_rules.get("forbidden_words", []))
|
||||
duration = db_rules.get("duration") or {}
|
||||
min_seconds = duration.get("min_seconds")
|
||||
max_seconds = duration.get("max_seconds")
|
||||
|
||||
# 硬编码兜底
|
||||
hardcoded = _platform_rules.get(request.platform, {})
|
||||
for rule in hardcoded.get("rules", []):
|
||||
if rule.get("type") == "forbidden_word":
|
||||
platform_forbidden.extend(rule.get("words", []))
|
||||
forbidden_words.update(rule.get("words", []))
|
||||
elif rule.get("type") == "duration" and min_seconds is None:
|
||||
if rule.get("min_seconds") is not None:
|
||||
min_seconds = rule["min_seconds"]
|
||||
if rule.get("max_seconds") is not None and max_seconds is None:
|
||||
max_seconds = rule["max_seconds"]
|
||||
|
||||
for phrase in required_phrases:
|
||||
for word in platform_forbidden:
|
||||
if word in phrase:
|
||||
# 2. 检查卖点/必选短语与违禁词冲突
|
||||
phrases = list(request.brief_rules.get("required_phrases", []))
|
||||
phrases += list(request.brief_rules.get("selling_points", []))
|
||||
for phrase in phrases:
|
||||
for word in forbidden_words:
|
||||
if word in str(phrase):
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"要求使用:{phrase}",
|
||||
platform_rule=f"平台禁止:{word}",
|
||||
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
|
||||
brief_rule=f"卖点包含:{phrase}",
|
||||
platform_rule=f"{request.platform} 禁止使用:{word}",
|
||||
suggestion=f"卖点 '{phrase}' 包含违禁词 '{word}',建议修改表述",
|
||||
))
|
||||
|
||||
# 3. 检查时长冲突
|
||||
brief_min = request.brief_rules.get("min_duration")
|
||||
brief_max = request.brief_rules.get("max_duration")
|
||||
if min_seconds and brief_max and brief_max < min_seconds:
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"Brief 最长时长:{brief_max}秒",
|
||||
platform_rule=f"{request.platform} 最短要求:{min_seconds}秒",
|
||||
suggestion=f"Brief 最长 {brief_max}s 低于平台最短要求 {min_seconds}s,视频可能不达标",
|
||||
))
|
||||
if max_seconds and brief_min and brief_min > max_seconds:
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"Brief 最短时长:{brief_min}秒",
|
||||
platform_rule=f"{request.platform} 最长限制:{max_seconds}秒",
|
||||
suggestion=f"Brief 最短 {brief_min}s 超过平台最长限制 {max_seconds}s,建议调整",
|
||||
))
|
||||
|
||||
return RuleValidateResponse(conflicts=conflicts)
|
||||
|
||||
|
||||
|
||||
@ -345,7 +345,7 @@ class TestRuleConflictDetection:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
"""检测 Brief 与平台规则冲突(required_phrases)"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
@ -353,7 +353,7 @@ class TestRuleConflictDetection:
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"required_phrases": ["绝对有效"], # 可能违反平台规则
|
||||
"required_phrases": ["绝对有效"],
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -386,6 +386,190 @@ class TestRuleConflictDetection:
|
||||
assert "platform_rule" in conflict
|
||||
assert "suggestion" in conflict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selling_points_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""selling_points 字段也参与冲突检测"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"selling_points": ["100%纯天然成分", "绝对安全"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert len(data["conflicts"]) >= 2 # "100%" 和 "绝对" 都命中
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_conflict_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""无冲突时返回空列表"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"selling_points": ["温和护肤", "适合敏感肌"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert data["conflicts"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_conflict_brief_max_below_platform_min(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""Brief 最长时长低于平台最短要求"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin", # 硬编码 min_seconds=7
|
||||
"brief_rules": {
|
||||
"max_duration": 5,
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert len(data["conflicts"]) >= 1
|
||||
assert any("时长" in c["brief_rule"] for c in data["conflicts"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_rules_participate_in_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""DB 中 active 的规则参与冲突检测"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
|
||||
# 创建并确认一条包含自定义违禁词的 DB 平台规则
|
||||
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||
rule_id = create_resp.json()["id"]
|
||||
|
||||
custom_rules = {
|
||||
"forbidden_words": ["自定义违禁词ABC"],
|
||||
"restricted_words": [],
|
||||
"duration": {"min_seconds": 15, "max_seconds": 120},
|
||||
"content_requirements": [],
|
||||
"other_rules": [],
|
||||
}
|
||||
await client.put(
|
||||
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||
headers=headers,
|
||||
json={"parsed_rules": custom_rules},
|
||||
)
|
||||
|
||||
# 验证 DB 违禁词参与检测
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers=headers,
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"selling_points": ["这个自定义违禁词ABC很好"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert len(data["conflicts"]) >= 1
|
||||
assert any("自定义违禁词ABC" in c["suggestion"] for c in data["conflicts"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_duration_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""DB 规则中的时长限制参与检测"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
|
||||
# 创建 DB 规则:max_seconds=60
|
||||
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
|
||||
rule_id = create_resp.json()["id"]
|
||||
|
||||
custom_rules = {
|
||||
"forbidden_words": [],
|
||||
"restricted_words": [],
|
||||
"duration": {"min_seconds": 10, "max_seconds": 60},
|
||||
"content_requirements": [],
|
||||
"other_rules": [],
|
||||
}
|
||||
await client.put(
|
||||
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||
headers=headers,
|
||||
json={"parsed_rules": custom_rules},
|
||||
)
|
||||
|
||||
# Brief 最短时长 90s > 平台最长 60s → 冲突
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers=headers,
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "xiaohongshu",
|
||||
"brief_rules": {
|
||||
"min_duration": 90,
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert len(data["conflicts"]) >= 1
|
||||
assert any("最长限制" in c["platform_rule"] for c in data["conflicts"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_and_hardcoded_rules_merge(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""DB 规则与硬编码规则合并检测"""
|
||||
headers = {"X-Tenant-ID": tenant_id}
|
||||
|
||||
# DB 规则只包含自定义违禁词
|
||||
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||
rule_id = create_resp.json()["id"]
|
||||
|
||||
await client.put(
|
||||
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||
headers=headers,
|
||||
json={"parsed_rules": {
|
||||
"forbidden_words": ["DB专属词"],
|
||||
"restricted_words": [],
|
||||
"duration": None,
|
||||
"content_requirements": [],
|
||||
"other_rules": [],
|
||||
}},
|
||||
)
|
||||
|
||||
# selling_points 同时包含 DB 违禁词和硬编码违禁词
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers=headers,
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "douyin",
|
||||
"brief_rules": {
|
||||
"selling_points": ["这是DB专属词内容", "最好的选择"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
# 应同时检出 DB 违禁词和硬编码违禁词
|
||||
suggestions = [c["suggestion"] for c in data["conflicts"]]
|
||||
assert any("DB专属词" in s for s in suggestions)
|
||||
assert any("最好" in s for s in suggestions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_platform_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
"""未知平台返回空冲突(无硬编码规则,无 DB 规则)"""
|
||||
response = await client.post(
|
||||
"/api/v1/rules/validate",
|
||||
headers={"X-Tenant-ID": tenant_id},
|
||||
json={
|
||||
"brand_id": brand_id,
|
||||
"platform": "unknown_platform",
|
||||
"brief_rules": {
|
||||
"selling_points": ["最好的产品"],
|
||||
}
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
assert data["conflicts"] == []
|
||||
|
||||
|
||||
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user