feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用: - 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API - 添加 loading 骨架屏、error toast 提示、submitting 状态管理 - 数据映射:TaskResponse → 页面视图模型,处理类型差异 - 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo - Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等 - 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
54eaa54966
commit
a8be7bbca9
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -19,12 +19,17 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
File,
|
||||
MessageSquareWarning
|
||||
MessageSquareWarning,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { getPlatformInfo } from '@/lib/platforms'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
// 模拟脚本待审列表
|
||||
const mockScriptTasks = [
|
||||
{
|
||||
id: 'script-001',
|
||||
@@ -59,7 +64,6 @@ const mockScriptTasks = [
|
||||
},
|
||||
]
|
||||
|
||||
// 模拟视频待审列表
|
||||
const mockVideoTasks = [
|
||||
{
|
||||
id: 'video-001',
|
||||
@@ -112,23 +116,118 @@ const mockVideoTasks = [
|
||||
},
|
||||
]
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface UITask {
|
||||
id: string
|
||||
title: string
|
||||
fileName: string
|
||||
fileSize: string
|
||||
creatorName: string
|
||||
agencyName: string
|
||||
projectName: string
|
||||
platform: string
|
||||
aiScore: number
|
||||
submittedAt: string
|
||||
hasHighRisk: boolean
|
||||
agencyApproved: boolean
|
||||
isAppeal: boolean
|
||||
appealReason?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
// ==================== 映射函数 ====================
|
||||
|
||||
/**
|
||||
* 将后端 TaskResponse 映射为 UI 任务格式
|
||||
*/
|
||||
function mapTaskToUI(task: TaskResponse, type: 'script' | 'video'): UITask {
|
||||
const isScript = type === 'script'
|
||||
|
||||
// AI 评分:脚本用 script_ai_score,视频用 video_ai_score
|
||||
const aiScore = isScript
|
||||
? (task.script_ai_score ?? 0)
|
||||
: (task.video_ai_score ?? 0)
|
||||
|
||||
// AI 审核结果中检测是否有高风险(severity === 'high')
|
||||
const aiResult = isScript ? task.script_ai_result : task.video_ai_result
|
||||
const hasHighRisk = aiResult?.violations?.some(v => v.severity === 'high') ?? false
|
||||
|
||||
// 代理商审核状态
|
||||
const agencyStatus = isScript ? task.script_agency_status : task.video_agency_status
|
||||
const agencyApproved = agencyStatus === 'passed' || agencyStatus === 'force_passed'
|
||||
|
||||
// 文件名
|
||||
const fileName = isScript
|
||||
? (task.script_file_name ?? '未上传脚本')
|
||||
: (task.video_file_name ?? '未上传视频')
|
||||
|
||||
// 视频时长:后端返回秒数,转为 mm:ss 格式
|
||||
let duration: string | undefined
|
||||
if (!isScript && task.video_duration) {
|
||||
const minutes = Math.floor(task.video_duration / 60)
|
||||
const seconds = task.video_duration % 60
|
||||
duration = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化提交时间
|
||||
const submittedAt = formatDateTime(task.updated_at)
|
||||
|
||||
// 平台信息:后端目前不返回平台字段,默认 douyin
|
||||
const platform = 'douyin'
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
fileName,
|
||||
fileSize: isScript ? '--' : '--',
|
||||
creatorName: task.creator.name,
|
||||
agencyName: task.agency.name,
|
||||
projectName: task.project.name,
|
||||
platform,
|
||||
aiScore,
|
||||
submittedAt,
|
||||
hasHighRisk,
|
||||
agencyApproved,
|
||||
isAppeal: task.is_appeal,
|
||||
appealReason: task.appeal_reason ?? undefined,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为 YYYY-MM-DD HH:mm
|
||||
*/
|
||||
function formatDateTime(isoString: string): string {
|
||||
try {
|
||||
const d = new Date(isoString)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 子组件 ====================
|
||||
|
||||
function ScoreTag({ score }: { score: number }) {
|
||||
if (score >= 85) return <SuccessTag>{score}分</SuccessTag>
|
||||
if (score >= 70) return <WarningTag>{score}分</WarningTag>
|
||||
return <ErrorTag>{score}分</ErrorTag>
|
||||
}
|
||||
|
||||
type ScriptTask = typeof mockScriptTasks[0]
|
||||
type VideoTask = typeof mockVideoTasks[0]
|
||||
|
||||
function TaskCard({
|
||||
task,
|
||||
type,
|
||||
onPreview
|
||||
}: {
|
||||
task: ScriptTask | VideoTask
|
||||
task: UITask
|
||||
type: 'script' | 'video'
|
||||
onPreview: (task: ScriptTask | VideoTask, type: 'script' | 'video') => void
|
||||
onPreview: (task: UITask, type: 'script' | 'video') => void
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const href = type === 'script' ? `/brand/review/script/${task.id}` : `/brand/review/video/${task.id}`
|
||||
@@ -210,7 +309,7 @@ function TaskCard({
|
||||
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{task.fileSize}
|
||||
{'duration' in task && ` · ${task.duration}`}
|
||||
{task.duration && ` · ${task.duration}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -244,27 +343,106 @@ function TaskCard({
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListSkeleton({ count = 2 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border-subtle overflow-hidden animate-pulse">
|
||||
<div className="px-4 py-1.5 bg-bg-elevated border-b border-border-subtle">
|
||||
<div className="h-4 w-20 bg-bg-page rounded" />
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-48 bg-bg-page rounded" />
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-24 bg-bg-page rounded" />
|
||||
<div className="h-4 w-24 bg-bg-page rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-14 bg-bg-page rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page">
|
||||
<div className="w-10 h-10 rounded-lg bg-bg-elevated" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 w-40 bg-bg-elevated rounded" />
|
||||
<div className="h-3 w-20 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-3 w-28 bg-bg-page rounded" />
|
||||
<div className="h-3 w-32 bg-bg-page rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 主页面 ====================
|
||||
|
||||
export default function BrandReviewListPage() {
|
||||
const toast = useToast()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
|
||||
const [previewTask, setPreviewTask] = useState<{ task: ScriptTask | VideoTask; type: 'script' | 'video' } | null>(null)
|
||||
const [previewTask, setPreviewTask] = useState<{ task: UITask; type: 'script' | 'video' } | null>(null)
|
||||
|
||||
const filteredScripts = mockScriptTasks.filter(task =>
|
||||
// API 数据状态
|
||||
const [scriptTasks, setScriptTasks] = useState<UITask[]>([])
|
||||
const [videoTasks, setVideoTasks] = useState<UITask[]>([])
|
||||
const [loading, setLoading] = useState(!USE_MOCK)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 从 API 加载数据
|
||||
const fetchTasks = useCallback(async () => {
|
||||
if (USE_MOCK) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [scriptRes, videoRes] = await Promise.all([
|
||||
api.listTasks(1, 20, 'script_brand_review'),
|
||||
api.listTasks(1, 20, 'video_brand_review'),
|
||||
])
|
||||
|
||||
setScriptTasks(scriptRes.items.map(t => mapTaskToUI(t, 'script')))
|
||||
setVideoTasks(videoRes.items.map(t => mapTaskToUI(t, 'video')))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (USE_MOCK) {
|
||||
setScriptTasks(mockScriptTasks)
|
||||
setVideoTasks(mockVideoTasks)
|
||||
} else {
|
||||
fetchTasks()
|
||||
}
|
||||
}, [fetchTasks])
|
||||
|
||||
// 搜索过滤
|
||||
const filteredScripts = scriptTasks.filter(task =>
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredVideos = mockVideoTasks.filter(task =>
|
||||
const filteredVideos = videoTasks.filter(task =>
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// 计算申诉数量
|
||||
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
|
||||
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
|
||||
const appealScriptCount = scriptTasks.filter(t => t.isAppeal).length
|
||||
const appealVideoCount = videoTasks.filter(t => t.isAppeal).length
|
||||
|
||||
const handlePreview = (task: ScriptTask | VideoTask, type: 'script' | 'video') => {
|
||||
const handlePreview = (task: UITask, type: 'script' | 'video') => {
|
||||
setPreviewTask({ task, type })
|
||||
}
|
||||
|
||||
@@ -277,18 +455,27 @@ export default function BrandReviewListPage() {
|
||||
<p className="text-sm text-text-secondary mt-1">审核代理商提交的脚本和视频</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-text-secondary">待审核:</span>
|
||||
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
|
||||
{mockScriptTasks.length} 脚本
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
|
||||
{mockVideoTasks.length} 视频
|
||||
</span>
|
||||
{(appealScriptCount + appealVideoCount) > 0 && (
|
||||
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
|
||||
<MessageSquareWarning size={14} />
|
||||
{appealScriptCount + appealVideoCount} 申诉
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2 text-text-tertiary">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-secondary">待审核:</span>
|
||||
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
|
||||
{scriptTasks.length} 脚本
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
|
||||
{videoTasks.length} 视频
|
||||
</span>
|
||||
{(appealScriptCount + appealVideoCount) > 0 && (
|
||||
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
|
||||
<MessageSquareWarning size={14} />
|
||||
{appealScriptCount + appealVideoCount} 申诉
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,6 +523,16 @@ export default function BrandReviewListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载错误提示 */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-lg bg-accent-coral/10 border border-accent-coral/30 text-accent-coral text-sm flex items-center justify-between">
|
||||
<span>加载失败: {error}</span>
|
||||
<Button variant="secondary" size="sm" onClick={fetchTasks}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 脚本待审列表 */}
|
||||
@@ -346,12 +543,14 @@ export default function BrandReviewListPage() {
|
||||
<FileText size={18} className="text-accent-indigo" />
|
||||
脚本终审
|
||||
<span className="ml-auto text-sm font-normal text-text-secondary">
|
||||
{filteredScripts.length} 条待审
|
||||
{loading ? '...' : `${filteredScripts.length} 条待审`}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{filteredScripts.length > 0 ? (
|
||||
{loading ? (
|
||||
<TaskListSkeleton count={2} />
|
||||
) : filteredScripts.length > 0 ? (
|
||||
filteredScripts.map((task) => (
|
||||
<TaskCard key={task.id} task={task} type="script" onPreview={handlePreview} />
|
||||
))
|
||||
@@ -373,12 +572,14 @@ export default function BrandReviewListPage() {
|
||||
<Video size={18} className="text-purple-400" />
|
||||
视频终审
|
||||
<span className="ml-auto text-sm font-normal text-text-secondary">
|
||||
{filteredVideos.length} 条待审
|
||||
{loading ? '...' : `${filteredVideos.length} 条待审`}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{filteredVideos.length > 0 ? (
|
||||
{loading ? (
|
||||
<TaskListSkeleton count={3} />
|
||||
) : filteredVideos.length > 0 ? (
|
||||
filteredVideos.map((task) => (
|
||||
<TaskCard key={task.id} task={task} type="video" onPreview={handlePreview} />
|
||||
))
|
||||
@@ -437,10 +638,10 @@ export default function BrandReviewListPage() {
|
||||
<span>{previewTask?.task.fileName}</span>
|
||||
<span className="mx-2">·</span>
|
||||
<span>{previewTask?.task.fileSize}</span>
|
||||
{previewTask?.type === 'video' && 'duration' in (previewTask?.task || {}) && (
|
||||
{previewTask?.type === 'video' && previewTask?.task.duration && (
|
||||
<>
|
||||
<span className="mx-2">·</span>
|
||||
<span>{(previewTask.task as VideoTask).duration}</span>
|
||||
<span>{previewTask.task.duration}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -8,6 +8,8 @@ import { Modal, ConfirmModal } from '@/components/ui/Modal'
|
||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||
import { ReviewSteps, getBrandReviewSteps } from '@/components/ui/ReviewSteps'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
@@ -21,11 +23,13 @@ import {
|
||||
Download,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
MessageSquareWarning
|
||||
MessageSquareWarning,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 模拟脚本任务数据
|
||||
// Mock 脚本任务数据(USE_MOCK 模式使用)
|
||||
const mockScriptTask = {
|
||||
id: 'script-001',
|
||||
title: '夏日护肤推广脚本',
|
||||
@@ -35,7 +39,6 @@ const mockScriptTask = {
|
||||
submittedAt: '2026-02-06 14:30',
|
||||
aiScore: 88,
|
||||
status: 'brand_reviewing',
|
||||
// 文件信息
|
||||
file: {
|
||||
id: 'file-001',
|
||||
fileName: '夏日护肤推广_脚本v2.docx',
|
||||
@@ -44,7 +47,6 @@ const mockScriptTask = {
|
||||
fileUrl: '/demo/scripts/script-001.docx',
|
||||
uploadedAt: '2026-02-06 14:30',
|
||||
} as FileInfo,
|
||||
// 申诉信息
|
||||
isAppeal: false,
|
||||
appealReason: '',
|
||||
scriptContent: {
|
||||
@@ -78,6 +80,69 @@ const mockScriptTask = {
|
||||
},
|
||||
}
|
||||
|
||||
// 从 TaskResponse 映射出页面所需的数据结构
|
||||
function mapTaskToView(task: TaskResponse) {
|
||||
const violations = (task.script_ai_result?.violations || []).map((v, idx) => ({
|
||||
id: `v-${idx}`,
|
||||
type: v.type,
|
||||
content: v.content,
|
||||
suggestion: v.suggestion,
|
||||
severity: v.severity,
|
||||
}))
|
||||
|
||||
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
|
||||
id: `w-${idx}`,
|
||||
type: w.type,
|
||||
content: w.content,
|
||||
suggestion: w.suggestion,
|
||||
}))
|
||||
|
||||
const fileExtension = task.script_file_name?.split('.').pop()?.toLowerCase() || ''
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
doc: 'application/msword',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
rtf: 'application/rtf',
|
||||
}
|
||||
|
||||
const agencyResult = task.script_agency_status || 'pending'
|
||||
const agencyResultLabel = agencyResult === 'passed' ? '建议通过' : agencyResult === 'rejected' ? '建议驳回' : '待审核'
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
creatorName: task.creator.name,
|
||||
agencyName: task.agency.name,
|
||||
projectName: task.project.name,
|
||||
submittedAt: task.script_uploaded_at || task.created_at,
|
||||
aiScore: task.script_ai_score || 0,
|
||||
status: task.stage,
|
||||
file: {
|
||||
id: task.id,
|
||||
fileName: task.script_file_name || '未上传文件',
|
||||
fileSize: '',
|
||||
fileType: mimeTypeMap[fileExtension] || 'application/octet-stream',
|
||||
fileUrl: task.script_file_url || '',
|
||||
uploadedAt: task.script_uploaded_at || undefined,
|
||||
} as FileInfo,
|
||||
isAppeal: task.is_appeal,
|
||||
appealReason: task.appeal_reason || '',
|
||||
agencyReview: {
|
||||
reviewer: task.agency.name,
|
||||
result: agencyResult,
|
||||
resultLabel: agencyResultLabel,
|
||||
comment: task.script_agency_comment || '',
|
||||
reviewedAt: '',
|
||||
},
|
||||
aiAnalysis: {
|
||||
violations,
|
||||
softWarnings,
|
||||
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||
const steps = getBrandReviewSteps(taskStatus)
|
||||
const currentStep = steps.find(s => s.status === 'current')
|
||||
@@ -97,32 +162,142 @@ function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-6 bg-bg-elevated rounded w-1/3" />
|
||||
<div className="h-4 bg-bg-elevated rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BrandScriptReviewPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const toast = useToast()
|
||||
const taskId = params.id as string
|
||||
|
||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file')
|
||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||
const [loading, setLoading] = useState(!USE_MOCK)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [taskData, setTaskData] = useState<ReturnType<typeof mapTaskToView> | null>(null)
|
||||
|
||||
const task = mockScriptTask
|
||||
// 加载任务数据
|
||||
const loadTask = useCallback(async () => {
|
||||
if (USE_MOCK) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getTask(taskId)
|
||||
setTaskData(mapTaskToView(response))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId, toast])
|
||||
|
||||
const handleApprove = () => {
|
||||
setShowApproveModal(false)
|
||||
toast.success('审核通过')
|
||||
router.push('/brand/review')
|
||||
useEffect(() => {
|
||||
loadTask()
|
||||
}, [loadTask])
|
||||
|
||||
// USE_MOCK 模式下使用 mock 数据
|
||||
const task = USE_MOCK ? {
|
||||
...mockScriptTask,
|
||||
agencyReview: {
|
||||
...mockScriptTask.agencyReview,
|
||||
resultLabel: '建议通过',
|
||||
},
|
||||
aiAnalysis: {
|
||||
...mockScriptTask.aiAnalysis,
|
||||
softWarnings: [] as Array<{ id: string; type: string; content: string; suggestion: string }>,
|
||||
},
|
||||
} : taskData
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (USE_MOCK) {
|
||||
setShowApproveModal(false)
|
||||
toast.success('审核通过')
|
||||
router.push('/brand/review')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await api.reviewScript(taskId, { action: 'pass', comment: '' })
|
||||
setShowApproveModal(false)
|
||||
toast.success('审核通过')
|
||||
router.push('/brand/review')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '操作失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
const handleReject = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setShowRejectModal(false)
|
||||
toast.success('已驳回')
|
||||
router.push('/brand/review')
|
||||
|
||||
if (USE_MOCK) {
|
||||
setShowRejectModal(false)
|
||||
toast.success('已驳回')
|
||||
router.push('/brand/review')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
|
||||
setShowRejectModal(false)
|
||||
toast.success('已驳回')
|
||||
router.push('/brand/review')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '操作失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
// 数据未加载到
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-text-secondary mb-4">任务数据加载失败</p>
|
||||
<Button variant="secondary" onClick={() => router.back()}>返回</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -199,10 +374,12 @@ export default function BrandScriptReviewPage() {
|
||||
{/* 左侧:脚本内容 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* 文件信息卡片 */}
|
||||
<FileInfoCard
|
||||
file={task.file}
|
||||
onPreview={() => setShowFilePreview(true)}
|
||||
/>
|
||||
{task.file.fileUrl && (
|
||||
<FileInfoCard
|
||||
file={task.file}
|
||||
onPreview={() => setShowFilePreview(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'file' ? (
|
||||
<Card>
|
||||
@@ -213,7 +390,11 @@ export default function BrandScriptReviewPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FilePreview file={task.file} />
|
||||
{task.file.fileUrl ? (
|
||||
<FilePreview file={task.file} />
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary text-center py-8">暂无文件</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -226,22 +407,31 @@ export default function BrandScriptReviewPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
||||
<p className="text-text-primary">{task.scriptContent.opening}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
||||
<p className="text-text-primary">{task.scriptContent.productIntro}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
||||
<p className="text-text-primary">{task.scriptContent.demo}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
||||
<p className="text-text-primary">{task.scriptContent.closing}</p>
|
||||
</div>
|
||||
{USE_MOCK && 'scriptContent' in task ? (
|
||||
<>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
||||
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.opening}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
||||
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.productIntro}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
||||
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.demo}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
||||
<p className="text-text-primary">{(task as typeof mockScriptTask).scriptContent.closing}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FileText size={32} className="mx-auto text-text-tertiary mb-3" />
|
||||
<p className="text-sm text-text-tertiary">API 暂不支持解析内容预览,请切换到「原文件」查看</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -255,23 +445,35 @@ export default function BrandScriptReviewPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-full ${task.agencyReview.result === 'approved' ? 'bg-accent-green/20' : 'bg-accent-coral/20'}`}>
|
||||
{task.agencyReview.result === 'approved' ? (
|
||||
<CheckCircle size={20} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-accent-coral" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
||||
<SuccessTag>建议通过</SuccessTag>
|
||||
{task.agencyReview.comment ? (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-full ${task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved' ? 'bg-accent-green/20' : 'bg-accent-coral/20'}`}>
|
||||
{task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved' ? (
|
||||
<CheckCircle size={20} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-accent-coral" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
||||
{(task.agencyReview.result === 'passed' || task.agencyReview.result === 'approved') ? (
|
||||
<SuccessTag>{task.agencyReview.resultLabel}</SuccessTag>
|
||||
) : task.agencyReview.result === 'rejected' ? (
|
||||
<ErrorTag>{task.agencyReview.resultLabel}</ErrorTag>
|
||||
) : (
|
||||
<PendingTag>{task.agencyReview.resultLabel}</PendingTag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
||||
{task.agencyReview.reviewedAt && (
|
||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">暂无代理商审核意见</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -304,7 +506,7 @@ export default function BrandScriptReviewPage() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{v.type}</WarningTag>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">「{v.content}」</p>
|
||||
<p className="text-sm text-text-primary">{v.content}</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -314,54 +516,81 @@ export default function BrandScriptReviewPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 合规检查 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
合规检查
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{check.passed ? (
|
||||
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-text-primary">{check.item}</span>
|
||||
{check.note && (
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
|
||||
)}
|
||||
{/* 软性提醒 */}
|
||||
{task.aiAnalysis.softWarnings && task.aiAnalysis.softWarnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
软性提醒 ({task.aiAnalysis.softWarnings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.softWarnings.map((w) => (
|
||||
<div key={w.id} className="p-3 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<PendingTag>{w.type}</PendingTag>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">{w.content}</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{w.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 合规检查 - 仅 mock 模式显示 */}
|
||||
{USE_MOCK && 'complianceChecks' in task.aiAnalysis && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
合规检查
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(task.aiAnalysis as typeof mockScriptTask.aiAnalysis).complianceChecks.map((check, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{check.passed ? (
|
||||
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-text-primary">{check.item}</span>
|
||||
{check.note && (
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 卖点覆盖 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{task.aiAnalysis.sellingPoints.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -373,10 +602,12 @@ export default function BrandScriptReviewPage() {
|
||||
项目:{task.projectName}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||
驳回
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
@@ -408,8 +639,11 @@ export default function BrandScriptReviewPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
@@ -22,22 +22,92 @@ import {
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
MessageSquareWarning
|
||||
MessageSquareWarning,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 模拟视频任务数据
|
||||
const mockVideoTask = {
|
||||
// ==================== AI 审核结果类型 ====================
|
||||
|
||||
interface AIReviewResult {
|
||||
score: number
|
||||
violations: Array<{
|
||||
type: string
|
||||
content: string
|
||||
severity: string
|
||||
suggestion: string
|
||||
timestamp?: number
|
||||
source?: string
|
||||
}>
|
||||
soft_warnings: Array<{
|
||||
type: string
|
||||
content: string
|
||||
suggestion: string
|
||||
}>
|
||||
summary?: string
|
||||
}
|
||||
|
||||
// ==================== 本地视图数据类型 ====================
|
||||
|
||||
interface VideoTaskView {
|
||||
id: string
|
||||
title: string
|
||||
creatorName: string
|
||||
agencyName: string
|
||||
projectName: string
|
||||
submittedAt: string
|
||||
duration: number
|
||||
aiScore: number
|
||||
status: string
|
||||
file: FileInfo
|
||||
isAppeal: boolean
|
||||
appealReason: string
|
||||
agencyReview: {
|
||||
reviewer: string
|
||||
result: string
|
||||
comment: string
|
||||
reviewedAt?: string
|
||||
}
|
||||
hardViolations: Array<{
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
timestamp: number
|
||||
source: string
|
||||
riskLevel: string
|
||||
aiConfidence: number
|
||||
suggestion: string
|
||||
}>
|
||||
sentimentWarnings: Array<{
|
||||
id: string
|
||||
type: string
|
||||
timestamp: number
|
||||
content: string
|
||||
riskLevel: string
|
||||
}>
|
||||
sellingPointsCovered: Array<{
|
||||
point: string
|
||||
covered: boolean
|
||||
timestamp: number
|
||||
}>
|
||||
aiSummary?: string
|
||||
}
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
|
||||
const mockVideoTask: VideoTaskView = {
|
||||
id: 'video-001',
|
||||
title: '夏日护肤推广',
|
||||
creatorName: '小美护肤',
|
||||
agencyName: '星耀传媒',
|
||||
projectName: 'XX品牌618推广',
|
||||
submittedAt: '2026-02-06 15:00',
|
||||
duration: 135, // 秒
|
||||
duration: 135,
|
||||
aiScore: 85,
|
||||
status: 'brand_reviewing',
|
||||
// 文件信息
|
||||
file: {
|
||||
id: 'file-video-001',
|
||||
fileName: '夏日护肤_成片v2.mp4',
|
||||
@@ -47,8 +117,7 @@ const mockVideoTask = {
|
||||
uploadedAt: '2026-02-06 15:00',
|
||||
duration: '02:15',
|
||||
thumbnail: '/demo/videos/video-001-thumb.jpg',
|
||||
} as FileInfo,
|
||||
// 申诉信息
|
||||
},
|
||||
isAppeal: false,
|
||||
appealReason: '',
|
||||
agencyReview: {
|
||||
@@ -90,12 +159,88 @@ const mockVideoTask = {
|
||||
],
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDurationString(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function severityToRiskLevel(severity: string): string {
|
||||
if (severity === 'high' || severity === 'critical') return 'high'
|
||||
if (severity === 'medium') return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
/** 将后端 TaskResponse 映射为本地视图数据 */
|
||||
function mapTaskToView(task: TaskResponse): VideoTaskView {
|
||||
const aiResult = task.video_ai_result as AIReviewResult | null | undefined
|
||||
|
||||
const hardViolations = (aiResult?.violations || []).map((v, idx) => ({
|
||||
id: `v${idx}`,
|
||||
type: v.type,
|
||||
content: v.content,
|
||||
timestamp: v.timestamp ?? 0,
|
||||
source: v.source ?? 'unknown',
|
||||
riskLevel: severityToRiskLevel(v.severity),
|
||||
aiConfidence: 0.9,
|
||||
suggestion: v.suggestion,
|
||||
}))
|
||||
|
||||
const sentimentWarnings = (aiResult?.soft_warnings || []).map((w, idx) => ({
|
||||
id: `s${idx}`,
|
||||
type: w.type,
|
||||
timestamp: 0,
|
||||
content: w.content,
|
||||
riskLevel: 'low',
|
||||
}))
|
||||
|
||||
const duration = task.video_duration || 0
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
creatorName: task.creator.name,
|
||||
agencyName: task.agency.name,
|
||||
projectName: task.project.name,
|
||||
submittedAt: task.video_uploaded_at || task.created_at,
|
||||
duration,
|
||||
aiScore: task.video_ai_score || 0,
|
||||
status: task.stage,
|
||||
file: {
|
||||
id: task.id,
|
||||
fileName: task.video_file_name || '视频文件',
|
||||
fileSize: '',
|
||||
fileType: 'video/mp4',
|
||||
fileUrl: task.video_file_url || '',
|
||||
uploadedAt: task.video_uploaded_at || task.created_at,
|
||||
duration: formatDurationString(duration),
|
||||
thumbnail: task.video_thumbnail_url || undefined,
|
||||
},
|
||||
isAppeal: task.is_appeal,
|
||||
appealReason: task.appeal_reason || '',
|
||||
agencyReview: {
|
||||
reviewer: task.agency.name,
|
||||
result: task.video_agency_status === 'passed' || task.video_agency_status === 'force_passed' ? 'approved' : (task.video_agency_status || 'pending'),
|
||||
comment: task.video_agency_comment || '',
|
||||
},
|
||||
hardViolations,
|
||||
sentimentWarnings,
|
||||
// 卖点覆盖目前后端暂无,保留空数组
|
||||
sellingPointsCovered: [],
|
||||
aiSummary: aiResult?.summary,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 子组件 ====================
|
||||
|
||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||
const steps = getBrandReviewSteps(taskStatus)
|
||||
const currentStep = steps.find(s => s.status === 'current')
|
||||
@@ -121,10 +266,48 @@ function RiskLevelTag({ level }: { level: string }) {
|
||||
return <SuccessTag>低风险</SuccessTag>
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{/* 顶部导航骨架 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 bg-bg-elevated rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-72 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 流程进度骨架 */}
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
{/* 主体骨架 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="aspect-video bg-bg-elevated rounded-xl" />
|
||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-24 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-40 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 主页面 ====================
|
||||
|
||||
export default function BrandVideoReviewPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const toast = useToast()
|
||||
const taskId = params.id as string
|
||||
|
||||
const [task, setTask] = useState<VideoTaskView | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||
@@ -133,28 +316,91 @@ export default function BrandVideoReviewPage() {
|
||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||
const [videoError, setVideoError] = useState(false)
|
||||
|
||||
const task = mockVideoTask
|
||||
// 加载任务数据
|
||||
const loadTask = useCallback(async () => {
|
||||
if (!taskId) return
|
||||
|
||||
const handleApprove = () => {
|
||||
setShowApproveModal(false)
|
||||
toast.success('审核通过!')
|
||||
router.push('/brand/review')
|
||||
if (USE_MOCK) {
|
||||
// Mock 模式下使用静态数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
setTask(mockVideoTask)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getTask(taskId)
|
||||
setTask(mapTaskToView(response))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载任务失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
loadTask()
|
||||
}, [loadTask])
|
||||
|
||||
// 通过审核
|
||||
const handleApprove = async () => {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
await api.reviewVideo(taskId, { action: 'pass', comment: '' })
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
setShowApproveModal(false)
|
||||
toast.success('审核通过!')
|
||||
router.push('/brand/review')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '操作失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
// 驳回审核
|
||||
const handleReject = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setShowRejectModal(false)
|
||||
toast.success('已驳回')
|
||||
router.push('/brand/review')
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
setShowRejectModal(false)
|
||||
toast.success('已驳回')
|
||||
router.push('/brand/review')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '操作失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载中状态
|
||||
if (loading || !task) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
// 计算问题时间点用于进度条展示
|
||||
const timelineMarkers = [
|
||||
...task.hardViolations.map(v => ({ time: v.timestamp, type: 'hard' as const })),
|
||||
...task.sentimentWarnings.map(w => ({ time: w.timestamp, type: 'soft' as const })),
|
||||
...task.sentimentWarnings.filter(w => w.timestamp > 0).map(w => ({ time: w.timestamp, type: 'soft' as const })),
|
||||
...task.sellingPointsCovered.filter(s => s.covered).map(s => ({ time: s.timestamp, type: 'selling' as const })),
|
||||
].sort((a, b) => a.time - b.time)
|
||||
|
||||
@@ -249,41 +495,43 @@ export default function BrandVideoReviewPage() {
|
||||
)}
|
||||
</div>
|
||||
{/* 智能进度条 */}
|
||||
<div className="p-4 border-t border-border-subtle">
|
||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
||||
{/* 时间标记点 */}
|
||||
{timelineMarkers.map((marker, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
|
||||
marker.type === 'hard' ? 'bg-accent-coral' : marker.type === 'soft' ? 'bg-orange-500' : 'bg-accent-green'
|
||||
}`}
|
||||
style={{ left: `${(marker.time / task.duration) * 100}%` }}
|
||||
title={`${formatTimestamp(marker.time)} - ${marker.type === 'hard' ? '硬性问题' : marker.type === 'soft' ? '舆情提示' : '卖点覆盖'}`}
|
||||
/>
|
||||
))}
|
||||
{task.duration > 0 && (
|
||||
<div className="p-4 border-t border-border-subtle">
|
||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
||||
{/* 时间标记点 */}
|
||||
{timelineMarkers.map((marker, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
|
||||
marker.type === 'hard' ? 'bg-accent-coral' : marker.type === 'soft' ? 'bg-orange-500' : 'bg-accent-green'
|
||||
}`}
|
||||
style={{ left: `${(marker.time / task.duration) * 100}%` }}
|
||||
title={`${formatTimestamp(marker.time)} - ${marker.type === 'hard' ? '硬性问题' : marker.type === 'soft' ? '舆情提示' : '卖点覆盖'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-tertiary mt-1">
|
||||
<span>0:00</span>
|
||||
<span>{formatTimestamp(task.duration)}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-coral rounded-full" />
|
||||
硬性问题
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full" />
|
||||
舆情提示
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-green rounded-full" />
|
||||
卖点覆盖
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-tertiary mt-1">
|
||||
<span>0:00</span>
|
||||
<span>{formatTimestamp(task.duration)}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-coral rounded-full" />
|
||||
硬性问题
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full" />
|
||||
舆情提示
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-green rounded-full" />
|
||||
卖点覆盖
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -307,10 +555,16 @@ export default function BrandVideoReviewPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-text-primary">{task.agencyReview.reviewer}</span>
|
||||
<SuccessTag>建议通过</SuccessTag>
|
||||
{task.agencyReview.result === 'approved' ? (
|
||||
<SuccessTag>建议通过</SuccessTag>
|
||||
) : (
|
||||
<ErrorTag>建议驳回</ErrorTag>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment}</p>
|
||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||
<p className="text-text-secondary text-sm">{task.agencyReview.comment || '暂无评论'}</p>
|
||||
{task.agencyReview.reviewedAt && (
|
||||
<p className="text-xs text-text-tertiary mt-2">{task.agencyReview.reviewedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -326,7 +580,7 @@ export default function BrandVideoReviewPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">
|
||||
视频整体合规,发现{task.hardViolations.length}处硬性问题和{task.sentimentWarnings.length}处舆情提示,代理商已确认处理。
|
||||
{task.aiSummary || `视频整体合规,发现${task.hardViolations.length}处硬性问题和${task.sentimentWarnings.length}处舆情提示,代理商已确认处理。`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -343,6 +597,9 @@ export default function BrandVideoReviewPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{task.hardViolations.length === 0 && (
|
||||
<p className="text-sm text-text-tertiary py-2">未发现硬性合规问题</p>
|
||||
)}
|
||||
{task.hardViolations.map((v) => (
|
||||
<div key={v.id} className={`p-3 rounded-lg border ${checkedViolations[v.id] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -355,9 +612,11 @@ export default function BrandVideoReviewPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ErrorTag>{v.type}</ErrorTag>
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||
{v.timestamp > 0 && (
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
||||
<p className="text-sm font-medium text-text-primary">{v.content}</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,10 +639,12 @@ export default function BrandVideoReviewPage() {
|
||||
<div key={w.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{w.type}</WarningTag>
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
||||
{w.timestamp > 0 && (
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-orange-400">{w.content}</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">⚠️ 软性风险仅作提示,不强制拦截</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不强制拦截</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -391,31 +652,33 @@ export default function BrandVideoReviewPage() {
|
||||
)}
|
||||
|
||||
{/* 卖点覆盖 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.sellingPointsCovered.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
|
||||
<div className="flex items-center gap-2">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
{task.sellingPointsCovered.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.sellingPointsCovered.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
|
||||
<div className="flex items-center gap-2">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
{sp.covered && sp.timestamp > 0 && (
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
{sp.covered && (
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -427,10 +690,12 @@ export default function BrandVideoReviewPage() {
|
||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{task.hardViolations.length} 个问题
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)}>
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||
驳回
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)}>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
@@ -445,7 +710,7 @@ export default function BrandVideoReviewPage() {
|
||||
onConfirm={handleApprove}
|
||||
title="确认通过"
|
||||
message="确定要通过此视频的审核吗?通过后达人将收到通知。"
|
||||
confirmText="确认通过"
|
||||
confirmText={submitting ? '提交中...' : '确认通过'}
|
||||
/>
|
||||
|
||||
{/* 驳回弹窗 */}
|
||||
@@ -455,7 +720,7 @@ export default function BrandVideoReviewPage() {
|
||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||
<p className="text-sm font-medium text-text-primary mb-2">已选问题 ({Object.values(checkedViolations).filter(Boolean).length})</p>
|
||||
{task.hardViolations.filter(v => checkedViolations[v.id]).map(v => (
|
||||
<div key={v.id} className="text-sm text-text-secondary">• {v.type}: {v.content}</div>
|
||||
<div key={v.id} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
|
||||
))}
|
||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
||||
@@ -471,8 +736,11 @@ export default function BrandVideoReviewPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject}>确认驳回</Button>
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user