Your Name f634879f1e fix: 修复达人端上传脚本按钮无响应问题
UploadView 组件的按钮缺少 onClick 处理,现改为点击后导航至专用上传页面。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:07:22 +08:00

844 lines
38 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 { useParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import {
Upload, Check, X, Folder, Bell, MessageCircle,
XCircle, CheckCircle, Loader2, Scan, ArrowLeft,
Bot, Users, Building2, Clock, FileText, Video,
ChevronRight, AlertTriangle, Download, Eye, Target, Ban,
ChevronDown, ChevronUp, File
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
// 前端 UI 使用的任务阶段类型
type TaskPhase = 'script' | 'video'
type TaskStage =
| 'upload'
| 'ai_reviewing'
| 'ai_result'
| 'agency_reviewing'
| 'agency_rejected'
| 'brand_reviewing'
| 'brand_approved'
| 'brand_rejected'
type Issue = {
title: string
description: string
timestamp?: string
severity?: 'error' | 'warning'
}
type ReviewLog = {
time: string
message: string
status: 'done' | 'loading' | 'pending'
}
type TaskData = {
id: string
title: string
subtitle: string
phase: TaskPhase
stage: TaskStage
progress?: number
issues?: Issue[]
reviewLogs?: ReviewLog[]
rejectionReason?: string
submittedAt?: string
scriptContent?: string
}
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 将后端 TaskResponse 映射为前端 UI 的 TaskData
function mapApiTaskToTaskData(task: TaskResponse): TaskData {
const stage = task.stage
let phase: TaskPhase = 'script'
let uiStage: TaskStage = 'upload'
let issues: Issue[] = []
let rejectionReason: string | undefined
let submittedAt: string | undefined
// 判断阶段
if (stage.startsWith('video_') || stage === 'completed') {
phase = 'video'
}
// 映射阶段
switch (stage) {
case 'script_upload': uiStage = 'upload'; break
case 'script_ai_review': uiStage = 'ai_reviewing'; break
case 'script_agency_review': uiStage = 'agency_reviewing'; submittedAt = task.script_uploaded_at || undefined; break
case 'script_brand_review': uiStage = 'brand_reviewing'; submittedAt = task.script_uploaded_at || undefined; break
case 'video_upload': uiStage = 'upload'; phase = 'video'; break
case 'video_ai_review': uiStage = 'ai_reviewing'; phase = 'video'; break
case 'video_agency_review': uiStage = 'agency_reviewing'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'video_brand_review': uiStage = 'brand_reviewing'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'completed': uiStage = 'brand_approved'; phase = 'video'; submittedAt = task.video_uploaded_at || undefined; break
case 'rejected': {
// 判断是哪个阶段被驳回
if (task.video_brand_status === 'rejected') {
phase = 'video'; uiStage = 'brand_rejected'
rejectionReason = task.video_brand_comment || undefined
} else if (task.video_agency_status === 'rejected') {
phase = 'video'; uiStage = 'agency_rejected'
rejectionReason = task.video_agency_comment || undefined
} else if (task.script_brand_status === 'rejected') {
phase = 'script'; uiStage = 'brand_rejected'
rejectionReason = task.script_brand_comment || undefined
} else if (task.script_agency_status === 'rejected') {
phase = 'script'; uiStage = 'agency_rejected'
rejectionReason = task.script_agency_comment || undefined
} else {
uiStage = 'ai_result'
}
break
}
}
// 处理驳回状态(非 rejected stage 但有驳回)
if (task.script_agency_status === 'rejected' && stage !== 'rejected') {
phase = 'script'; uiStage = 'agency_rejected'
rejectionReason = task.script_agency_comment || undefined
}
if (task.script_brand_status === 'rejected' && stage !== 'rejected') {
phase = 'script'; uiStage = 'brand_rejected'
rejectionReason = task.script_brand_comment || undefined
}
if (task.video_agency_status === 'rejected' && stage !== 'rejected') {
phase = 'video'; uiStage = 'agency_rejected'
rejectionReason = task.video_agency_comment || undefined
}
if (task.video_brand_status === 'rejected' && stage !== 'rejected') {
phase = 'video'; uiStage = 'brand_rejected'
rejectionReason = task.video_brand_comment || undefined
}
// 提取 AI 审核结果中的 issues
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
if (aiResult?.violations) {
issues = aiResult.violations.map(v => ({
title: v.type,
description: `${v.content}${v.suggestion ? `${v.suggestion}` : ''}`,
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
}))
}
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
return {
id: task.id,
title: task.name,
subtitle,
phase,
stage: uiStage,
issues: issues.length > 0 ? issues : undefined,
rejectionReason,
submittedAt,
}
}
// Mock Brief 数据
const mockBriefData = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
}
// Mock 任务数据
const mockTasksData: Record<string, TaskData> = {
'task-001': { id: 'task-001', title: 'XX品牌618推广', subtitle: '产品种草视频 · 当前步骤:上传脚本', phase: 'script', stage: 'upload' },
'task-002': { id: 'task-002', title: 'YY美妆新品', subtitle: '口播测评 · 当前步骤AI审核中', phase: 'script', stage: 'ai_reviewing', progress: 62, reviewLogs: [
{ time: '14:32:01', message: '脚本上传完成', status: 'done' },
{ time: '14:32:15', message: '任务规则已加载', status: 'done' },
{ time: '14:33:45', message: '正在分析品牌调性匹配度...', status: 'loading' },
]},
'task-003': { id: 'task-003', title: 'ZZ饮品夏日', subtitle: '探店Vlog · 发现2处问题', phase: 'script', stage: 'ai_result', issues: [
{ title: '检测到竞品提及', description: '脚本第3段提及了竞品「百事可乐」', severity: 'error' },
{ title: '禁用词语出现', description: '脚本中出现「最好喝」等绝对化用语', severity: 'error' },
]},
'task-004': { id: 'task-004', title: 'AA数码新品发布', subtitle: '开箱测评 · 审核通过', phase: 'video', stage: 'brand_approved', submittedAt: '2026-02-01 10:30' },
'task-008': { id: 'task-008', title: 'EE食品试吃', subtitle: '美食测评 · 脚本通过 · 待上传视频', phase: 'video', stage: 'upload' },
}
// ========== UI 组件 ==========
function StepIcon({ status, icon }: { status: 'done' | 'current' | 'error' | 'pending'; icon: 'upload' | 'bot' | 'users' | 'building' }) {
const IconMap = { upload: Upload, bot: Bot, users: Users, building: Building2 }
const Icon = IconMap[icon]
const getStyle = () => {
switch (status) {
case 'done': return 'bg-accent-green'
case 'current': return 'bg-accent-indigo'
case 'error': return 'bg-accent-coral'
default: return 'bg-bg-elevated border-[1.5px] border-border-subtle'
}
}
const iconColor = status === 'pending' ? 'text-text-tertiary' : 'text-white'
return (
<div className={cn('w-8 h-8 rounded-full flex items-center justify-center', getStyle())}>
{status === 'done' && <Check size={16} className={iconColor} />}
{status === 'current' && <Loader2 size={16} className={cn(iconColor, 'animate-spin')} />}
{status === 'error' && <X size={16} className={iconColor} />}
{status === 'pending' && <Icon size={16} className={iconColor} />}
</div>
)
}
function ReviewProgressBar({ task }: { task: TaskData }) {
const { stage } = task
const getStepStatus = (stepIndex: number): 'done' | 'current' | 'error' | 'pending' => {
const stageMap: Record<TaskStage, number> = {
'upload': 0, 'ai_reviewing': 1, 'ai_result': 1,
'agency_reviewing': 2, 'agency_rejected': 2,
'brand_reviewing': 3, 'brand_approved': 4, 'brand_rejected': 3,
}
const currentStepIndex = stageMap[stage]
const isError = stage === 'ai_result' || stage === 'agency_rejected' || stage === 'brand_rejected'
if (stepIndex < currentStepIndex) return 'done'
if (stepIndex === currentStepIndex) {
if (isError) return 'error'
if (stage === 'brand_approved') return 'done'
return 'current'
}
return 'pending'
}
const steps = [
{ label: '已提交', icon: 'upload' as const },
{ label: 'AI审核', icon: 'bot' as const },
{ label: '代理商', icon: 'users' as const },
{ label: '品牌方', icon: 'building' as const },
]
return (
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<h3 className="text-lg font-semibold text-text-primary mb-5">
{task.phase === 'script' ? '脚本审核流程' : '视频审核流程'}
</h3>
<div className="flex items-center">
{steps.map((step, index) => {
const status = getStepStatus(index)
return (
<div key={step.label} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-2 w-20">
<StepIcon status={status} icon={step.icon} />
<span className={cn(
'text-xs',
status === 'done' ? 'text-text-secondary' :
status === 'error' ? 'text-accent-coral font-semibold' :
status === 'current' ? 'text-accent-indigo font-semibold' :
'text-text-tertiary'
)}>{step.label}</span>
</div>
{index < steps.length - 1 && (
<div className={cn('h-0.5 flex-1', getStepStatus(index) === 'done' ? 'bg-accent-green' : 'bg-border-subtle')} />
)}
</div>
)
})}
</div>
</div>
)
}
// Brief 组件
function AgencyBriefSection({ toast, briefData }: {
toast: ReturnType<typeof useToast>
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; required: boolean }[]; blacklistWords: { id: string; word: string; reason: string }[] }
}) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
return (
<>
<div className="bg-bg-card rounded-2xl card-shadow border border-accent-indigo/30">
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">Brief </span>
</div>
<button type="button" onClick={() => setIsExpanded(!isExpanded)} className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors">
{isExpanded ? <ChevronUp className="w-5 h-5 text-text-tertiary" /> : <ChevronDown className="w-5 h-5 text-text-tertiary" />}
</button>
</div>
{isExpanded && (
<div className="p-4 space-y-4">
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-accent-indigo" />
</h4>
<div className="space-y-2">
{briefData.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-bg-elevated rounded-xl">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button type="button" onClick={() => setPreviewFile(file)} className="p-2 hover:bg-bg-page rounded-lg transition-colors">
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button type="button" onClick={() => handleDownload(file)} className="p-2 hover:bg-bg-page rounded-lg transition-colors">
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
))}
</div>
</div>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Ban className="w-4 h-4 text-accent-coral" /> 使
</h4>
<div className="flex flex-wrap gap-2">
{briefData.blacklistWords.map((bw) => (
<span key={bw.id} className="px-2 py-1 text-xs bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30">
{bw.word}
</span>
))}
</div>
</div>
</div>
)}
</div>
<Modal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} title={previewFile?.name || '文件预览'} size="lg">
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}></Button>
{previewFile && <Button onClick={() => handleDownload(previewFile)}><Download className="w-4 h-4" /></Button>}
</div>
</div>
</Modal>
</>
)
}
function UploadView({ task, toast, briefData }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData }) {
const router = useRouter()
const { id } = useParams()
const isScript = task.phase === 'script'
const uploadPath = isScript ? `/creator/task/${id}/script` : `/creator/task/${id}/video`
const handleUploadClick = () => {
router.push(uploadPath)
}
return (
<div className="flex flex-col gap-6 h-full">
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</h3>
<p className="text-sm text-text-tertiary">{isScript ? '支持粘贴文本或上传文档' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo"></span>
</div>
<div
className="flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card min-h-[400px] border-border-subtle hover:border-accent-indigo/50 cursor-pointer"
onClick={handleUploadClick}
>
<div className="w-20 h-20 rounded-full bg-accent-indigo/15 flex items-center justify-center">
<Upload className="w-10 h-10 text-accent-indigo" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold text-text-primary"></p>
<p className="text-sm text-text-tertiary">{isScript ? '支持 .doc、.docx、.txt 格式' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
</div>
<button type="button" onClick={handleUploadClick} className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity">
<Upload className="w-5 h-5" />
{isScript ? '上传脚本文档' : '上传视频文件'}
</button>
</div>
</div>
)
}
function AIReviewingView({ task }: { task: TaskData }) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="bg-bg-card rounded-2xl p-10 card-shadow flex flex-col items-center gap-8 w-full max-w-md">
<div className="flex items-center gap-2 px-4 py-2 bg-bg-elevated rounded-lg">
<Folder className="w-3.5 h-3.5 text-text-tertiary" />
<span className="text-xs font-medium text-text-tertiary">
{task.phase === 'script' ? '脚本内容审核' : '视频内容审核'} ·
</span>
</div>
<div className="relative w-[180px] h-[180px] flex items-center justify-center">
<div className="absolute inset-0 rounded-full bg-gradient-radial from-accent-indigo/50 via-accent-indigo/20 to-transparent animate-pulse" />
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center shadow-[0_0_24px_rgba(99,102,241,0.5)]">
<Scan className="w-8 h-8 text-white animate-pulse" />
</div>
</div>
<div className="flex flex-col items-center gap-2 w-full">
<h2 className="text-[22px] font-semibold text-text-primary">
AI {task.phase === 'script' ? '脚本' : '视频'}
</h2>
<p className="text-sm text-text-secondary"> 2-3 </p>
<div className="flex items-center gap-3 w-full pt-3">
<div className="flex-1 h-2 bg-bg-elevated rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-accent-indigo to-[#4F46E5] rounded-full transition-all duration-300" style={{ width: `${task.progress || 0}%` }} />
</div>
<span className="text-sm font-semibold text-accent-indigo">{task.progress || 0}%</span>
</div>
</div>
{task.reviewLogs && task.reviewLogs.length > 0 && (
<div className="w-full bg-bg-elevated rounded-xl p-5 flex flex-col gap-2.5">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-accent-green" />
<span className="text-xs font-medium text-text-secondary"></span>
</div>
<div className="flex flex-col gap-2">
{task.reviewLogs.map((log, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<span className="text-text-tertiary font-mono">{log.time}</span>
<span className={cn(log.status === 'done' ? 'text-text-secondary' : log.status === 'loading' ? 'text-accent-indigo' : 'text-text-tertiary')}>
{log.message}
</span>
{log.status === 'loading' && <Loader2 className="w-3 h-3 text-accent-indigo animate-spin" />}
</div>
))}
</div>
</div>
)}
<button type="button" className="flex items-center gap-2 px-6 py-3 rounded-xl bg-bg-page border border-border-subtle text-text-secondary text-[13px] font-medium">
<Bell className="w-4 h-4" />
</button>
</div>
</div>
)
}
function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => void }) {
const getTitle = () => {
switch (task.stage) {
case 'ai_result': return 'AI 审核结果'
case 'agency_rejected': return '代理商审核结果'
case 'brand_rejected': return '品牌方审核结果'
default: return '审核结果'
}
}
const getStatusText = () => {
switch (task.stage) {
case 'ai_result': return 'AI 检测到问题'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_rejected': return '品牌方审核驳回'
default: return '需要修改'
}
}
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1 flex flex-col">
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<XCircle className="w-6 h-6 text-accent-coral" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{getTitle()}</span>
<span className="text-sm text-accent-coral font-medium">{getStatusText()}</span>
</div>
</div>
{task.rejectionReason && (
<div className="py-4 border-b border-border-subtle">
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
</div>
)}
{task.issues && task.issues.length > 0 && (
<div className="py-4 flex flex-col gap-4 flex-1">
<span className="text-sm font-semibold text-text-primary"> {task.issues.length} </span>
<div className="flex flex-col gap-3">
{task.issues.map((issue, index) => (
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold',
issue.severity === 'error' ? 'bg-accent-coral/15 text-accent-coral' : 'bg-amber-500/15 text-amber-500'
)}>
{issue.severity === 'error' ? '违规' : '建议'}
</span>
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
</div>
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
<MessageCircle className="w-[18px] h-[18px]" />
</button>
<button type="button" className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
<Upload className="w-[18px] h-[18px]" />
</button>
</div>
</div>
</div>
)
}
function WaitingReviewView({ task }: { task: TaskData }) {
const isAgency = task.stage === 'agency_reviewing'
const title = isAgency ? '等待代理商审核' : '等待品牌方终审'
const description = isAgency ? '您的内容已进入代理商审核环节,请耐心等待' : '您的内容已进入品牌方终审环节,这是最后一步'
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-text-secondary" />
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
</div>
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-text-primary">{task.submittedAt || '刚刚'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary">AI审核</span>
<span className="text-sm text-accent-green font-medium"></span>
</div>
{isAgency && (
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-indigo font-medium">...</span>
</div>
)}
{!isAgency && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-green font-medium"></span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-tertiary"></span>
<span className="text-sm text-accent-indigo font-medium">...</span>
</div>
</>
)}
</div>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Clock className="w-6 h-6 text-accent-indigo" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{title}</span>
<span className="text-sm text-text-secondary">{description}</span>
</div>
</div>
<div className="bg-accent-indigo/10 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-accent-indigo flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-[13px] text-text-secondary">
{isAgency ? '代理商通常会在 1-2 个工作日内完成审核。' : '品牌方终审通常需要 1-3 个工作日。'}
</span>
</div>
</div>
</div>
</div>
</div>
)
}
function ApprovedView({ task }: { task: TaskData }) {
const isVideoPhase = task.phase === 'video'
return (
<div className="flex flex-col gap-6 h-full">
<ReviewProgressBar task={task} />
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-text-secondary" />
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
</div>
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-text-primary">{task.submittedAt || '2026-02-01 10:30'}</span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">AI审核</span><span className="text-sm text-accent-green font-medium"></span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-accent-green font-medium"></span></div>
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary"></span><span className="text-sm text-accent-green font-medium"></span></div>
</div>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-accent-green" />
<span className="text-lg font-semibold text-text-primary"></span>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-green/15 text-accent-green"></span>
</div>
<p className="text-sm text-text-secondary">
{isVideoPhase ? '恭喜!视频已通过所有审核,可以发布了' : '脚本已通过品牌方终审,请继续上传视频'}
</p>
</div>
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-accent-green" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-semibold text-text-primary">{isVideoPhase ? '全部审核通过' : '脚本审核通过'}</span>
<span className="text-sm text-text-secondary">{isVideoPhase ? '可以安排发布了' : '请在 7 天内上传视频'}</span>
</div>
</div>
<div className="bg-accent-green/10 rounded-xl p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-accent-green flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-text-primary">{isVideoPhase ? '恭喜完成!' : '下一步'}</span>
<span className="text-[13px] text-text-secondary">
{isVideoPhase ? '您的视频已通过全部审核流程,可以在平台发布了。' : '脚本已通过审核,请在 7 天内上传对应视频。'}
</span>
</div>
</div>
</div>
</div>
{!isVideoPhase && (
<div className="flex justify-center pt-4">
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
<Video className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
</button>
</div>
)}
</div>
)
}
// ========== 主页面 ==========
export default function TaskDetailPage() {
const params = useParams()
const router = useRouter()
const toast = useToast()
const { subscribe } = useSSE()
const taskId = params.id as string
const [taskData, setTaskData] = useState<TaskData | null>(null)
const [briefData, setBriefData] = useState(mockBriefData)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadTask = useCallback(async () => {
if (USE_MOCK) {
const mock = mockTasksData[taskId]
setTaskData(mock || null)
setIsLoading(false)
return
}
try {
const task = await api.getTask(taskId)
setTaskData(mapApiTaskToTaskData(task))
// 加载 Brief
if (task.project?.id) {
try {
const brief = await api.getBrief(task.project.id)
setBriefData({
files: (brief.attachments || []).map((a, i) => ({
id: a.id || `att-${i}`,
name: a.name,
size: a.size || '',
uploadedAt: brief.updated_at || '',
})),
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
id: `sp-${i}`,
content: sp.content,
required: sp.required,
})),
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
id: `bw-${i}`,
word: bw.word,
reason: bw.reason,
})),
})
} catch {
// Brief 可能不存在,不影响任务展示
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setIsLoading(false)
}
}, [taskId])
useEffect(() => {
loadTask()
}, [loadTask])
// SSE 实时更新
useEffect(() => {
const unsub1 = subscribe('task_updated', (data) => {
if ((data as { task_id?: string }).task_id === taskId) loadTask()
})
const unsub2 = subscribe('review_completed', (data) => {
if ((data as { task_id?: string }).task_id === taskId) loadTask()
})
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
if (isLoading) {
return (
<ResponsiveLayout role="creator">
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-accent-indigo animate-spin" />
</div>
</ResponsiveLayout>
)
}
if (error || !taskData) {
return (
<ResponsiveLayout role="creator">
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4">
<XCircle className="w-16 h-16 text-text-tertiary" />
<p className="text-lg text-text-secondary">{error || '任务不存在'}</p>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-xl bg-accent-indigo text-white text-sm font-medium">
</button>
</div>
</div>
</ResponsiveLayout>
)
}
const handleAppeal = () => {
router.push(`/creator/appeals/new?taskId=${taskId}`)
}
const renderContent = () => {
switch (taskData.stage) {
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} />
case 'ai_reviewing': return <AIReviewingView task={taskData} />
case 'ai_result':
case 'agency_rejected':
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} />
case 'agency_reviewing':
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
case 'brand_approved': return <ApprovedView task={taskData} />
default: return <div></div>
}
}
const getPageTitle = () => {
switch (taskData.stage) {
case 'upload': return taskData.phase === 'script' ? '上传脚本' : '上传视频'
case 'ai_reviewing': return 'AI 智能审核'
case 'ai_result': return 'AI 审核结果'
case 'agency_reviewing': return '等待代理商审核'
case 'agency_rejected': return '代理商审核驳回'
case 'brand_reviewing': return '等待品牌方终审'
case 'brand_approved': return '审核通过'
case 'brand_rejected': return '品牌方审核驳回'
default: return '任务详情'
}
}
return (
<ResponsiveLayout role="creator">
<div className="flex flex-col gap-6 h-full">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3 mb-1">
<button type="button" onClick={() => router.back()} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{taskData.title}</h1>
<p className="text-sm lg:text-[15px] text-text-secondary">{taskData.subtitle}</p>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{renderContent()}
</div>
</div>
</ResponsiveLayout>
)
}