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
+211 -96
View File
@@ -1,49 +1,41 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Check, X, CheckSquare, Video, Clock } from 'lucide-react'
import { ArrowLeft, Check, X, CheckSquare, Video, Clock, Loader2, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getPlatformInfo } from '@/lib/platforms'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse } from '@/types/task'
// 模拟待审核内容列表
const mockReviewItems = [
// ==================== Mock 数据 ====================
const mockReviewItems: TaskResponse[] = [
{
id: 'review-001',
title: '春季护肤新品体验分享',
creator: '小美',
agency: '代理商A',
platform: 'douyin',
reviewer: '张三',
reviewTime: '2小时前',
agencyOpinion: '内容符合Brief要求,卖点覆盖完整,建议通过。',
agencyStatus: 'passed',
aiScore: 12,
aiChecks: [
{ label: '合规检测', status: 'passed', description: '未检测到违禁词、竞品Logo等违规内容' },
{ label: '卖点覆盖', status: 'passed', description: '核心卖点覆盖率 95%' },
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
],
currentStep: 4, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
},
{
id: 'review-002',
title: '夏日清爽护肤推荐',
creator: '小红',
agency: '代理商B',
platform: 'xiaohongshu',
reviewer: '李四',
reviewTime: '5小时前',
agencyOpinion: '内容质量良好,但部分镜头略暗,建议后期调整后通过。',
agencyStatus: 'passed',
aiScore: 28,
aiChecks: [
{ label: '合规检测', status: 'passed', description: '未检测到违规内容' },
{ label: '卖点覆盖', status: 'warning', description: '核心卖点覆盖率 78%,建议增加产品特写' },
{ label: '品牌调性', status: 'passed', description: '视觉风格符合品牌调性' },
],
currentStep: 4,
id: 'TK000001',
name: '春季护肤新品体验分享',
sequence: 1,
stage: 'video_brand_review',
project: { id: 'PJ000001', name: 'XX品牌618推广' },
agency: { id: 'AG000001', name: '代理商A' },
creator: { id: 'CR000001', name: '小美' },
video_file_url: '/demo/video.mp4',
video_file_name: '春季护肤_成片v2.mp4',
video_duration: 135,
video_ai_score: 88,
video_ai_result: {
score: 88,
violations: [],
soft_warnings: [],
summary: '视频整体合规,卖点覆盖完整。',
},
video_agency_status: 'passed',
video_agency_comment: '内容符合Brief要求,卖点覆盖完整,建议通过。',
appeal_count: 0,
is_appeal: false,
created_at: '2026-02-06T14:00:00Z',
updated_at: '2026-02-06T16:00:00Z',
},
]
@@ -98,20 +90,99 @@ function ReviewProgressBar({ currentStep }: { currentStep: number }) {
)
}
function PageSkeleton() {
return (
<div className="flex flex-col gap-6 h-full min-h-0 animate-pulse">
<div className="h-12 bg-bg-elevated rounded-lg w-1/3" />
<div className="h-20 bg-bg-elevated rounded-2xl" />
<div className="flex gap-6 flex-1 min-h-0">
<div className="flex-1 h-96 bg-bg-elevated rounded-2xl" />
<div className="w-[380px] h-96 bg-bg-elevated rounded-2xl" />
</div>
</div>
)
}
export default function FinalReviewPage() {
const router = useRouter()
const toast = useToast()
const [selectedItem, setSelectedItem] = useState(mockReviewItems[0])
const [loading, setLoading] = useState(true)
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [feedback, setFeedback] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const platform = getPlatformInfo(selectedItem.platform)
const loadTasks = useCallback(async () => {
if (USE_MOCK) {
setTasks(mockReviewItems)
setLoading(false)
return
}
try {
// 加载品牌方待审任务(脚本 + 视频)
const [scriptRes, videoRes] = await Promise.all([
api.listTasks(1, 10, 'script_brand_review'),
api.listTasks(1, 10, 'video_brand_review'),
])
setTasks([...scriptRes.items, ...videoRes.items])
} catch (err) {
console.error('Failed to load review tasks:', err)
toast.error('加载待审任务失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => { loadTasks() }, [loadTasks])
if (loading) return <PageSkeleton />
if (tasks.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-text-tertiary">
<CheckSquare size={48} className="opacity-50" />
<p className="text-lg"></p>
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bg-elevated text-text-secondary text-sm font-medium"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
)
}
const selectedItem = tasks[selectedIndex]
const isVideoReview = selectedItem.stage === 'video_brand_review'
const aiResult = isVideoReview ? selectedItem.video_ai_result : selectedItem.script_ai_result
const aiScore = isVideoReview ? selectedItem.video_ai_score : selectedItem.script_ai_score
const agencyComment = isVideoReview ? selectedItem.video_agency_comment : selectedItem.script_agency_comment
const agencyStatus = isVideoReview ? selectedItem.video_agency_status : selectedItem.script_agency_status
const handleApprove = async () => {
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('已通过审核')
setIsSubmitting(false)
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
const reviewFn = isVideoReview ? api.reviewVideo : api.reviewScript
await reviewFn(selectedItem.id, { action: 'pass', comment: feedback || undefined })
}
toast.success('已通过审核')
setFeedback('')
// 移除已审核任务
const remaining = tasks.filter((_, i) => i !== selectedIndex)
setTasks(remaining)
if (selectedIndex >= remaining.length && remaining.length > 0) {
setSelectedIndex(remaining.length - 1)
}
} catch (err) {
toast.error('操作失败')
} finally {
setIsSubmitting(false)
}
}
const handleReject = async () => {
@@ -120,11 +191,25 @@ export default function FinalReviewPage() {
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
toast.success('已驳回')
setIsSubmitting(false)
setFeedback('')
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
const reviewFn = isVideoReview ? api.reviewVideo : api.reviewScript
await reviewFn(selectedItem.id, { action: 'reject', comment: feedback })
}
toast.success('已驳回')
setFeedback('')
const remaining = tasks.filter((_, i) => i !== selectedIndex)
setTasks(remaining)
if (selectedIndex >= remaining.length && remaining.length > 0) {
setSelectedIndex(remaining.length - 1)
}
} catch (err) {
toast.error('操作失败')
} finally {
setIsSubmitting(false)
}
}
return (
@@ -134,25 +219,30 @@ export default function FinalReviewPage() {
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary"></h1>
{platform && (
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-sm font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-sm font-medium ${
isVideoReview ? 'bg-purple-500/15 text-purple-400' : 'bg-accent-indigo/15 text-accent-indigo'
}`}>
{isVideoReview ? <Video size={14} /> : <FileText size={14} />}
{isVideoReview ? '视频终审' : '脚本终审'}
</span>
</div>
<p className="text-sm text-text-secondary">
{selectedItem.title} · : {selectedItem.creator}
{selectedItem.name} · : {selectedItem.creator.name}
</p>
</div>
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bg-elevated text-text-secondary text-sm font-medium"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-text-tertiary">
{selectedIndex + 1} / {tasks.length}
</span>
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bg-elevated text-text-secondary text-sm font-medium"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
</div>
{/* 审核流程进度 */}
@@ -161,22 +251,38 @@ export default function FinalReviewPage() {
<span className="text-sm font-semibold text-text-primary"></span>
<span className="text-xs text-accent-indigo font-medium"></span>
</div>
<ReviewProgressBar currentStep={selectedItem.currentStep} />
<ReviewProgressBar currentStep={4} />
</div>
{/* 主内容区 - 两栏布局 */}
<div className="flex gap-6 flex-1 min-h-0">
{/* 左侧 - 视频播放器 */}
{/* 左侧 - 预览 */}
<div className="flex-1 flex flex-col gap-4">
<div className="flex-1 bg-bg-card rounded-2xl card-shadow flex items-center justify-center">
<div className="w-[640px] h-[360px] rounded-xl bg-black flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
<Video className="w-10 h-10 text-text-tertiary" />
{isVideoReview ? (
selectedItem.video_file_url ? (
<video
className="w-full h-full rounded-2xl"
controls
src={selectedItem.video_file_url}
>
</video>
) : (
<div className="flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-full bg-[#1A1A1E] flex items-center justify-center">
<Video className="w-10 h-10 text-text-tertiary" />
</div>
<p className="text-sm text-text-tertiary"></p>
</div>
<p className="text-sm text-text-tertiary"></p>
)
) : (
<div className="flex flex-col items-center gap-4 p-8">
<FileText className="w-16 h-16 text-accent-indigo/50" />
<p className="text-text-secondary">{selectedItem.script_file_name || '脚本预览'}</p>
<p className="text-xs text-text-tertiary"></p>
</div>
</div>
)}
</div>
</div>
@@ -188,16 +294,20 @@ export default function FinalReviewPage() {
<span className="text-base font-semibold text-text-primary"></span>
<span className={cn(
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
selectedItem.agencyStatus === 'passed' ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral'
agencyStatus === 'passed' || agencyStatus === 'force_passed'
? 'bg-accent-green/15 text-accent-green'
: 'bg-accent-coral/15 text-accent-coral'
)}>
{selectedItem.agencyStatus === 'passed' ? '已通过' : '需修改'}
{agencyStatus === 'passed' || agencyStatus === 'force_passed' ? '已通过' : '需修改'}
</span>
</div>
<div className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
<span className="text-xs text-text-tertiary">
{selectedItem.agency} - {selectedItem.reviewer} · {selectedItem.reviewTime}
{selectedItem.agency.name}
</span>
<p className="text-[13px] text-text-secondary">{selectedItem.agencyOpinion}</p>
<p className="text-[13px] text-text-secondary">
{agencyComment || '无评论'}
</p>
</div>
</div>
@@ -207,29 +317,34 @@ export default function FinalReviewPage() {
<span className="text-base font-semibold text-text-primary">AI </span>
<span className={cn(
'px-3 py-1.5 rounded-lg text-[13px] font-semibold',
selectedItem.aiScore < 30 ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-amber/15 text-accent-amber'
(aiScore || 0) >= 80 ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-amber/15 text-accent-amber'
)}>
: {selectedItem.aiScore}
: {aiScore || '-'}
</span>
</div>
<div className="flex flex-col gap-3">
{selectedItem.aiChecks.map((check, index) => (
<div key={index} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<CheckSquare className={cn(
'w-4 h-4',
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
)} />
<span className={cn(
'text-sm font-semibold',
check.status === 'passed' ? 'text-accent-green' : 'text-accent-amber'
)}>
{check.label} · {check.status === 'passed' ? '通过' : '警告'}
</span>
{aiResult?.violations && aiResult.violations.length > 0 ? (
aiResult.violations.map((v, idx) => (
<div key={idx} className="bg-bg-elevated rounded-[10px] p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-accent-coral" />
<span className="text-sm font-semibold text-accent-coral">{v.type}</span>
</div>
<p className="text-[13px] text-text-secondary">{v.content}</p>
{v.suggestion && (
<p className="text-xs text-accent-indigo">{v.suggestion}</p>
)}
</div>
<p className="text-[13px] text-text-secondary">{check.description}</p>
))
) : (
<div className="bg-bg-elevated rounded-[10px] p-3 flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-accent-green" />
<span className="text-sm font-semibold text-accent-green"></span>
</div>
))}
)}
{aiResult?.summary && (
<p className="text-xs text-text-tertiary mt-1">{aiResult.summary}</p>
)}
</div>
</div>
@@ -245,7 +360,7 @@ export default function FinalReviewPage() {
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-green text-white font-semibold disabled:opacity-50"
>
<Check className="w-[18px] h-[18px]" />
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <Check className="w-[18px] h-[18px]" />}
</button>
<button
@@ -254,7 +369,7 @@ export default function FinalReviewPage() {
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl bg-accent-coral text-white font-semibold disabled:opacity-50"
>
<X className="w-[18px] h-[18px]" />
{isSubmitting ? <Loader2 className="w-[18px] h-[18px] animate-spin" /> : <X className="w-[18px] h-[18px]" />}
</button>
</div>
@@ -262,7 +377,7 @@ export default function FinalReviewPage() {
{/* 终审意见 */}
<div className="flex flex-col gap-2">
<label className="text-[13px] font-medium text-text-secondary">
</label>
<textarea
value={feedback}