Your Name a8be7bbca9 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>
2026-02-09 16:29:43 +08:00

590 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
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'
import { Button } from '@/components/ui/Button'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
import {
ArrowLeft,
FileText,
CheckCircle,
XCircle,
AlertTriangle,
User,
Clock,
Eye,
Shield,
Download,
MessageSquareWarning,
Loader2
} from 'lucide-react'
import { FilePreview, 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 mockScriptTask = {
id: 'script-001',
title: '夏日护肤推广脚本',
creatorName: '小美护肤',
projectName: 'XX品牌618推广',
submittedAt: '2026-02-06 14:30',
aiScore: 88,
status: 'agent_reviewing',
// 文件信息
file: {
id: 'file-001',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileUrl: '/demo/scripts/script-001.docx', // 实际开发时替换为真实URL
uploadedAt: '2026-02-06 14:30',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
// 脚本内容AI解析后的结构化内容用于展示
scriptContent: {
opening: '大家好!今天给大家分享一款超级好用的夏日护肤神器~',
productIntro: '这款XX品牌的防晒霜SPF50+PA++++,真的是夏天出门必备!质地轻薄不油腻,涂上去清清爽爽的。',
demo: '我先在手背上试一下,大家看,延展性特别好,轻轻一抹就推开了,完全不会搓泥。',
closing: '夏天防晒真的很重要姐妹们赶紧冲链接在小黄车1号链接',
},
aiAnalysis: {
violations: [
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium' },
],
complianceChecks: [
{ item: '品牌名称正确', passed: true },
{ item: 'SPF标注准确', passed: true },
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
{ item: '引导语规范', passed: true },
],
sellingPoints: [
{ point: 'SPF50+ PA++++', covered: true },
{ point: '轻薄质地', covered: true },
{ point: '不油腻', covered: true },
{ point: '延展性好', covered: true },
],
},
}
// 从 TaskResponse 映射到页面视图模型
function mapTaskToViewModel(task: TaskResponse) {
return {
id: task.id,
title: task.name,
creatorName: task.creator?.name || '未知达人',
projectName: task.project?.name || '未知项目',
submittedAt: task.script_uploaded_at || task.created_at,
aiScore: task.script_ai_score ?? 0,
status: task.stage,
file: {
id: `file-${task.id}`,
fileName: task.script_file_name || '未知文件',
fileSize: '',
fileType: 'application/octet-stream',
fileUrl: task.script_file_url || '',
uploadedAt: task.script_uploaded_at || task.created_at,
} as FileInfo,
isAppeal: task.is_appeal,
appealReason: task.appeal_reason || '',
scriptContent: {
opening: '',
productIntro: '',
demo: '',
closing: '',
},
aiAnalysis: {
violations: (task.script_ai_result?.violations || []).map((v, idx) => ({
id: `v${idx + 1}`,
type: v.type,
content: v.content,
suggestion: v.suggestion,
severity: v.severity,
})),
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w) => ({
item: w.type,
passed: false,
note: w.content,
})),
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
},
aiSummary: task.script_ai_result?.summary || '',
}
}
type ScriptTaskViewModel = ReturnType<typeof mapTaskToViewModel>
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
const steps = getAgencyReviewSteps(taskStatus)
const currentStep = steps.find(s => s.status === 'current')
return (
<Card className="mb-6">
<CardContent className="py-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-sm text-accent-indigo font-medium">
{currentStep?.label || '代理商审核'}
</span>
</div>
<ReviewSteps steps={steps} />
</CardContent>
</Card>
)
}
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/4" />
</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-32 bg-bg-elevated rounded-xl" />
<div className="h-64 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 AgencyScriptReviewPage() {
const router = useRouter()
const toast = useToast()
const params = useParams()
const taskId = params.id as string
const [loading, setLoading] = useState(!USE_MOCK)
const [submitting, setSubmitting] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [showForcePassModal, setShowForcePassModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [forcePassReason, setForcePassReason] = useState('')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
const [showFilePreview, setShowFilePreview] = useState(false)
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
const loadTask = useCallback(async () => {
if (USE_MOCK) return
setLoading(true)
try {
const data = await api.getTask(taskId)
setTask(mapTaskToViewModel(data))
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '加载任务详情失败'
toast.error(message)
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadTask()
}, [loadTask])
const handleApprove = async () => {
if (USE_MOCK) {
setShowApproveModal(false)
toast.success('已提交品牌方终审')
router.push('/agency/review')
return
}
setSubmitting(true)
try {
await api.reviewScript(taskId, { action: 'pass' })
setShowApproveModal(false)
toast.success('已提交品牌方终审')
router.push('/agency/review')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '操作失败'
toast.error(message)
} finally {
setSubmitting(false)
}
}
const handleReject = async () => {
if (!rejectReason.trim()) {
toast.error('请填写驳回原因')
return
}
if (USE_MOCK) {
setShowRejectModal(false)
toast.success('已驳回')
router.push('/agency/review')
return
}
setSubmitting(true)
try {
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
setShowRejectModal(false)
toast.success('已驳回')
router.push('/agency/review')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '操作失败'
toast.error(message)
} finally {
setSubmitting(false)
}
}
const handleForcePass = async () => {
if (!forcePassReason.trim()) {
toast.error('请填写强制通过原因')
return
}
if (USE_MOCK) {
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
return
}
setSubmitting(true)
try {
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
setShowForcePassModal(false)
toast.success('已强制通过并提交品牌方终审')
router.push('/agency/review')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '操作失败'
toast.error(message)
} finally {
setSubmitting(false)
}
}
if (loading) {
return <LoadingSkeleton />
}
return (
<div className="space-y-4">
{/* 顶部导航 */}
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
{task.creatorName}
</span>
<span className="flex items-center gap-1">
<Clock size={14} />
{task.submittedAt}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setViewMode('file')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'file' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('parsed')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'parsed' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
AI解析
</button>
</div>
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:脚本内容 */}
<div className="lg:col-span-2 space-y-4">
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
{viewMode === 'file' ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<FilePreview file={task.file} />
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
AI
<span className="text-xs font-normal text-text-tertiary ml-2">AI </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{task.aiSummary ? (
<div className="p-4 bg-bg-elevated rounded-lg">
<div className="text-xs text-accent-indigo font-medium mb-2">AI </div>
<p className="text-text-primary">{task.aiSummary}</p>
</div>
) : null}
<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>
</CardContent>
</Card>
)}
</div>
{/* 右侧AI 分析面板 */}
<div className="space-y-4">
{/* AI 评分 */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-sm text-text-secondary">AI </span>
<span className={`text-3xl font-bold ${task.aiScore >= 85 ? 'text-accent-green' : task.aiScore >= 70 ? 'text-yellow-400' : 'text-accent-coral'}`}>
{task.aiScore}
</span>
</div>
</CardContent>
</Card>
{/* 违规检测 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle size={16} className="text-orange-500" />
({task.aiAnalysis.violations.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiAnalysis.violations.map((v) => (
<div key={v.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>{v.type}</WarningTag>
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
{task.aiAnalysis.violations.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</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>
)}
</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>
))}
{task.aiAnalysis.sellingPoints.length === 0 && (
<p className="text-sm text-text-tertiary text-center py-4"></p>
)}
</CardContent>
</Card>
</div>
</div>
{/* 底部决策栏 */}
<Card className="sticky bottom-4 shadow-lg">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="text-sm text-text-secondary">
{task.projectName}
</div>
<div className="flex gap-3">
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 通过确认弹窗 */}
<ConfirmModal
isOpen={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={handleApprove}
title="确认通过"
message="确定要通过此脚本的审核吗?通过后将提交给品牌方进行终审。"
confirmText="确认通过"
/>
{/* 驳回弹窗 */}
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
<div className="space-y-4">
<p className="text-text-secondary text-sm"></p>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="请详细说明驳回原因..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div>
<div className="flex gap-3 justify-end">
<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>
{/* 强制通过弹窗 */}
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
<div className="space-y-4">
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<p className="text-sm text-yellow-400">
<AlertTriangle size={14} className="inline mr-1" />
</p>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="例如:内容符合品牌调性,建议通过"
value={forcePassReason}
onChange={(e) => setForcePassReason(e.target.value)}
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}></Button>
<Button onClick={handleForcePass} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin mr-1" /> : null}
</Button>
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}