feat: AI 审核自动驳回 + 功效词可配置 + UI 修复

- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段
- 功效词可配置:从硬编码改为品牌方在规则页面自行管理
- 驳回通知:AI 驳回时只通知达人,含具体原因
- 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口
- 规则页面:新增"功效词"分类
- 种子数据:新增 6 条默认功效词
- 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-11 20:24:32 +08:00
co-authored by Claude Opus 4.6
parent 0ef7650c09
commit 0b3dfa3c52
10 changed files with 476 additions and 190 deletions
+64 -37
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
@@ -69,6 +69,30 @@ export default function AgenciesManagePage() {
// 操作菜单状态
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
const menuRef = useRef<HTMLDivElement>(null)
const handleToggleMenu = (agencyId: string, e: React.MouseEvent<HTMLButtonElement>) => {
if (openMenuId === agencyId) {
setOpenMenuId(null)
return
}
const rect = e.currentTarget.getBoundingClientRect()
setMenuPos({ top: rect.bottom + 4, left: rect.right - 160 }) // 160 = menu width
setOpenMenuId(agencyId)
}
// 点击外部关闭菜单
useEffect(() => {
if (!openMenuId) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [openMenuId])
// 删除确认弹窗状态
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
@@ -351,35 +375,13 @@ export default function AgenciesManagePage() {
<StatusTag forcePass={agency.force_pass_enabled} />
</td>
<td className="px-6 py-4">
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
>
<MoreVertical size={16} />
</Button>
{openMenuId === agency.id && (
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
<button
type="button"
onClick={() => handleOpenAssign(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleOpenDelete(agency)}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleToggleMenu(agency.id, e)}
>
<MoreVertical size={16} />
</Button>
</td>
</tr>
))}
@@ -396,6 +398,38 @@ export default function AgenciesManagePage() {
</CardContent>
</Card>
{/* 操作菜单(fixed 定位,不受 overflow 裁剪) */}
{openMenuId && (
<div
ref={menuRef}
className="fixed w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-50 overflow-hidden"
style={{ top: menuPos.top, left: menuPos.left }}
>
<button
type="button"
onClick={() => {
const agency = agencies.find(a => a.id === openMenuId)
if (agency) handleOpenAssign(agency)
}}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
>
<FolderPlus size={14} className="text-text-secondary" />
</button>
<button
type="button"
onClick={() => {
const agency = agencies.find(a => a.id === openMenuId)
if (agency) handleOpenDelete(agency)
}}
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
{/* 邀请代理商弹窗 */}
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
<div className="space-y-4">
@@ -539,13 +573,6 @@ export default function AgenciesManagePage() {
</div>
</Modal>
{/* 点击其他地方关闭菜单 */}
{openMenuId && (
<div
className="fixed inset-0 z-0"
onClick={() => setOpenMenuId(null)}
/>
)}
</div>
)
}
+117 -17
View File
@@ -34,21 +34,67 @@ const providerOptions: { value: AIProvider | string; label: string }[] = [
{ value: 'moonshot', label: 'Moonshot' },
]
// Mock 可用模型列表
// 预设可用模型列表
const mockModels: Record<string, ModelInfo[]> = {
text: [
// Anthropic Claude
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
// OpenAI
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
{ id: 'o1', name: 'o1' },
{ id: 'o3-mini', name: 'o3-mini' },
// Google
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
// DeepSeek
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
// 通义千问
{ id: 'qwen-max', name: '通义千问 Max' },
{ id: 'qwen-plus', name: '通义千问 Plus' },
{ id: 'qwen-turbo', name: '通义千问 Turbo' },
// 豆包
{ id: 'doubao-pro-256k', name: '豆包 Pro 256K' },
{ id: 'doubao-pro-32k', name: '豆包 Pro 32K' },
// 智谱
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
{ id: 'glm-4', name: 'GLM-4' },
// Moonshot
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
],
vision: [
// Anthropic Claude (原生多模态)
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
// OpenAI
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
// Google
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
// 通义千问 VL
{ id: 'qwen-vl-max', name: '通义千问 VL Max' },
{ id: 'qwen-vl-plus', name: '通义千问 VL Plus' },
// 智谱
{ id: 'glm-4v-plus', name: 'GLM-4V Plus' },
{ id: 'glm-4v', name: 'GLM-4V' },
],
audio: [
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
{ id: 'whisper-large-v3-turbo', name: 'Whisper Large V3 Turbo' },
{ id: 'whisper-medium', name: 'Whisper Medium' },
{ id: 'sensevoice-v1', name: 'SenseVoice V1 (阿里)' },
{ id: 'paraformer-realtime-v2', name: 'Paraformer V2 (阿里)' },
],
}
@@ -83,6 +129,9 @@ export default function AIConfigPage() {
const [maxTokens, setMaxTokens] = useState(2000)
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
const [customLlmModel, setCustomLlmModel] = useState('')
const [customVisionModel, setCustomVisionModel] = useState('')
const [customAsrModel, setCustomAsrModel] = useState('')
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
text: { status: 'idle' },
@@ -101,9 +150,26 @@ export default function AIConfigPage() {
setBaseUrl(config.base_url)
setApiKey('') // API key is masked, don't fill it
setIsConfigured(config.is_configured)
setLlmModel(config.models.text)
setVisionModel(config.models.vision)
setAsrModel(config.models.audio)
const models = availableModels
// 如果后端返回的模型不在预设列表中,设为自定义
if (config.models.text && !(models.text || []).some(m => m.id === config.models.text)) {
setLlmModel('__custom__')
setCustomLlmModel(config.models.text)
} else {
setLlmModel(config.models.text)
}
if (config.models.vision && !(models.vision || []).some(m => m.id === config.models.vision)) {
setVisionModel('__custom__')
setCustomVisionModel(config.models.vision)
} else {
setVisionModel(config.models.vision)
}
if (config.models.audio && !(models.audio || []).some(m => m.id === config.models.audio)) {
setAsrModel('__custom__')
setCustomAsrModel(config.models.audio)
} else {
setAsrModel(config.models.audio)
}
setTemperature(config.parameters.temperature)
setMaxTokens(config.parameters.max_tokens)
if (config.available_models && Object.keys(config.available_models).length > 0) {
@@ -126,6 +192,10 @@ export default function AIConfigPage() {
useEffect(() => { loadConfig() }, [loadConfig])
// 获取实际使用的模型 ID(自定义时用输入值)
const getActualModel = (selected: string, custom: string) =>
selected === '__custom__' ? custom : selected
const handleTestConnection = async () => {
setTestResults({
text: { status: 'testing' },
@@ -148,7 +218,7 @@ export default function AIConfigPage() {
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***', // use existing key if not changed
models: { text: llmModel, vision: visionModel, audio: asrModel },
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
})
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
for (const [key, r] of Object.entries(result.results)) {
@@ -184,7 +254,7 @@ export default function AIConfigPage() {
provider: provider as AIProvider,
base_url: baseUrl,
api_key: apiKey || '***',
models: { text: llmModel, vision: visionModel, audio: asrModel },
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
parameters: { temperature, max_tokens: maxTokens },
})
}
@@ -304,32 +374,52 @@ export default function AIConfigPage() {
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
onChange={(e) => { setLlmModel(e.target.value); if (e.target.value !== '__custom__') setCustomLlmModel('') }}
>
{(availableModels.text || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
<p className="text-xs text-text-tertiary mt-2"> Brief </p>
{llmModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customLlmModel}
onChange={(e) => setCustomLlmModel(e.target.value)}
placeholder="输入模型 ID,如 deepseek-chat"
/>
)}
<p className="text-xs text-text-tertiary mt-2"> Brief </p>
</div>
{/* 视频分析模型 */}
{/* 视觉理解模型 */}
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Eye size={16} className="text-accent-green" />
<span className="font-medium text-text-primary"> (Vision)</span>
<span className="font-medium text-text-primary"> (Vision)</span>
{getTestStatusIcon('vision')}
</div>
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={visionModel}
onChange={(e) => setVisionModel(e.target.value)}
onChange={(e) => { setVisionModel(e.target.value); if (e.target.value !== '__custom__') setCustomVisionModel('') }}
>
{(availableModels.vision || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
<p className="text-xs text-text-tertiary mt-2">/</p>
{visionModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customVisionModel}
onChange={(e) => setCustomVisionModel(e.target.value)}
placeholder="输入模型 ID,如 gpt-4o"
/>
)}
<p className="text-xs text-text-tertiary mt-2"> logo</p>
</div>
{/* 音频解析模型 */}
@@ -342,12 +432,22 @@ export default function AIConfigPage() {
<select
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={asrModel}
onChange={(e) => setAsrModel(e.target.value)}
onChange={(e) => { setAsrModel(e.target.value); if (e.target.value !== '__custom__') setCustomAsrModel('') }}
>
{(availableModels.audio || []).map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
))}
<option value="__custom__">...</option>
</select>
{asrModel === '__custom__' && (
<input
type="text"
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
value={customAsrModel}
onChange={(e) => setCustomAsrModel(e.target.value)}
placeholder="输入模型 ID,如 whisper-large-v3"
/>
)}
<p className="text-xs text-text-tertiary mt-2"></p>
</div>
</CardContent>
+27 -34
View File
@@ -119,6 +119,7 @@ const mockWhitelist: WhitelistResponse[] = [
const categoryOptions = [
{ value: '极限词', label: '极限词' },
{ value: '功效词', label: '功效词' },
{ value: '虚假宣称', label: '虚假宣称' },
{ value: '价格欺诈', label: '价格欺诈' },
{ value: '平台规则', label: '平台规则' },
@@ -720,9 +721,9 @@ export default function RulesPage() {
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' ? '确认规则' : '查看详情'}
title="编辑规则"
>
{rule.status === 'draft' ? <Edit3 size={16} /> : <Eye size={16} />}
<Edit3 size={16} />
</button>
<button
type="button"
@@ -1054,29 +1055,25 @@ export default function RulesPage() {
{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>
)}
<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 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>
{/* 限制词 */}
@@ -1116,11 +1113,9 @@ export default function RulesPage() {
<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>
)}
<button type="button" onClick={() => removeContentReq(i)} className="text-text-tertiary hover:text-accent-coral">
<X size={14} />
</button>
</div>
))}
</div>
@@ -1145,14 +1140,8 @@ export default function RulesPage() {
{/* 操作按钮 */}
<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"
@@ -1166,6 +1155,10 @@ export default function RulesPage() {
</Button>
)}
<Button onClick={handleConfirmRule} disabled={submitting} loading={submitting}>
<CheckCircle size={16} />
{selectedRule.status === 'draft' ? '确认生效' : '保存修改'}
</Button>
</div>
</div>
)}
+26 -2
View File
@@ -35,6 +35,8 @@ type ScriptTaskUI = {
brandName: string
scriptStatus: string
scriptFile: string | null
aiAutoRejected?: boolean
aiRejectReason?: string
aiResult: null | {
score: number
dimensions?: ReviewDimensions
@@ -56,8 +58,11 @@ type BriefUI = {
function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
const stage = task.stage
let status = 'pending_upload'
const aiAutoRejected = task.script_ai_result?.ai_auto_rejected === true
switch (stage) {
case 'script_upload': status = 'pending_upload'; break
case 'script_upload':
status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'
break
case 'script_ai_review': status = 'ai_reviewing'; break
case 'script_agency_review': status = 'agent_reviewing'; break
case 'script_brand_review': status = 'brand_reviewing'; break
@@ -99,6 +104,8 @@ function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
brandName: task.project?.brand_name || '',
scriptStatus: status,
scriptFile: task.script_file_name || null,
aiAutoRejected,
aiRejectReason: task.script_ai_result?.ai_reject_reason,
aiResult,
agencyReview,
brandReview,
@@ -596,7 +603,7 @@ export default function CreatorScriptPage() {
const getStatusDisplay = () => {
const map: Record<string, string> = {
pending_upload: '待上传脚本', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
pending_upload: '待上传脚本', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
}
@@ -622,6 +629,23 @@ export default function CreatorScriptPage() {
<AgencyBriefSection toast={toast} briefData={briefData} />
{task.scriptStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
{task.scriptStatus === 'ai_rejected' && (
<>
<Card className="border-accent-coral/30 bg-accent-coral/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<XCircle size={20} className="text-accent-coral mt-0.5 flex-shrink-0" />
<div>
<p className="text-text-primary font-medium">AI </p>
{task.aiRejectReason && <p className="text-sm text-text-secondary mt-1">{task.aiRejectReason}</p>}
</div>
</div>
</CardContent>
</Card>
<AIResultSection task={task} />
<UploadSection taskId={taskId} onUploaded={loadTask} />
</>
)}
{task.scriptStatus === 'ai_reviewing' && <AIReviewingSection />}
{task.scriptStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
{task.scriptStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
+24 -2
View File
@@ -22,6 +22,8 @@ type VideoTaskUI = {
brandName: string
videoStatus: string
videoFile: string | null
aiAutoRejected?: boolean
aiRejectReason?: string
aiResult: null | {
score: number
hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }>
@@ -36,8 +38,9 @@ type VideoTaskUI = {
function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
const stage = task.stage
let status = 'pending_upload'
const aiAutoRejected = task.video_ai_result?.ai_auto_rejected === true
switch (stage) {
case 'video_upload': status = 'pending_upload'; break
case 'video_upload': status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'; break
case 'video_ai_review': status = 'ai_reviewing'; break
case 'video_agency_review': status = 'agent_reviewing'; break
case 'video_brand_review': status = 'brand_reviewing'; break
@@ -80,6 +83,8 @@ function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
brandName: task.project?.brand_name || '',
videoStatus: status,
videoFile: task.video_file_name || null,
aiAutoRejected,
aiRejectReason: task.video_ai_result?.ai_reject_reason,
aiResult,
agencyReview,
brandReview,
@@ -371,7 +376,7 @@ export default function CreatorVideoPage() {
const getStatusDisplay = () => {
const map: Record<string, string> = {
pending_upload: '待上传视频', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
pending_upload: '待上传视频', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
}
@@ -395,6 +400,23 @@ export default function CreatorVideoPage() {
<Card><CardContent className="py-4"><ReviewSteps steps={getReviewSteps(task.videoStatus)} /></CardContent></Card>
{task.videoStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
{task.videoStatus === 'ai_rejected' && (
<>
<Card className="border-accent-coral/30 bg-accent-coral/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<XCircle size={20} className="text-accent-coral mt-0.5 flex-shrink-0" />
<div>
<p className="text-text-primary font-medium"> AI </p>
{task.aiRejectReason && <p className="text-sm text-text-secondary mt-1">{task.aiRejectReason}</p>}
</div>
</div>
</CardContent>
</Card>
<AIResultSection task={task} />
<UploadSection taskId={taskId} onUploaded={loadTask} />
</>
)}
{task.videoStatus === 'ai_reviewing' && <AIReviewingSection />}
{task.videoStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
{task.videoStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
+3
View File
@@ -85,6 +85,9 @@ export interface AIReviewResult {
dimensions?: ReviewDimensions
selling_point_matches?: SellingPointMatchResult[]
brief_match_detail?: BriefMatchDetail
ai_auto_rejected?: boolean
ai_reject_reason?: string
ai_available?: boolean
violations: Array<{
type: string
content: string