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
+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>