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:
Your Name
2026-02-09 16:29:43 +08:00
co-authored by Claude Opus 4.6
parent 54eaa54966
commit a8be7bbca9
24 changed files with 5244 additions and 1845 deletions
+234 -33
View File
@@ -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>
+334 -100
View File
@@ -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>
+357 -89
View File
@@ -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>