Your Name e4959d584f feat: 完善代理商端业务逻辑与前后端框架
主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:27:31 +08:00

636 lines
22 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 } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
Upload, Check, X, Folder, Bell, Play, MessageCircle,
XCircle, CheckCircle, Loader2, Scan, ArrowLeft
} from 'lucide-react'
import { DesktopLayout } from '@/components/layout/DesktopLayout'
import { MobileLayout } from '@/components/layout/MobileLayout'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
import type { TaskResponse } from '@/types/task'
// 任务状态类型
type TaskStatus = 'pending_script' | 'pending_video' | 'ai_reviewing' | 'agency_reviewing' | 'need_revision' | 'passed'
type RequirementProfile = {
title?: string
platform?: string
deadline?: string
progress?: number
statusHint?: TaskStatus
issues?: Array<{ title: string; description: string; timestamp?: string }>
reviewLogs?: Array<{ time: string; message: string; status: 'done' | 'loading' | 'pending' }>
}
type TaskDetail = {
id: string
title: string
platform: string
deadline: string
status: TaskStatus
currentStep: number
progress?: number
issues?: Array<{ title: string; description: string; timestamp?: string }>
reviewLogs?: Array<{ time: string; message: string; status: 'done' | 'loading' | 'pending' }>
}
// 任务配置(占位数据)
const taskRequirementProfiles: Record<string, RequirementProfile> = {
'task-001': {
title: 'XX品牌618推广',
platform: '抖音',
deadline: '2026-02-10',
statusHint: 'pending_script',
},
'task-002': {
title: 'YY美妆新品',
platform: '小红书',
deadline: '2026-02-15',
progress: 62,
statusHint: 'ai_reviewing',
reviewLogs: [
{ time: '14:32:01', message: '视频上传完成', status: 'done' },
{ time: '14:32:15', message: '任务规则已加载', status: 'done' },
{ time: '14:32:28', message: '开始 ASR 语音识别', status: 'done' },
{ time: '14:33:45', message: '正在分析视觉合规性问题...', status: 'loading' },
],
},
'task-003': {
title: 'ZZ饮品夏日',
platform: '抖音',
deadline: '2026-02-08',
statusHint: 'need_revision',
issues: [
{
title: '检测到竞品 Logo',
description: '画面中 0:15-0:18 出现竞品「百事可乐」的 Logo可能造成合规风险。',
timestamp: '0:15',
},
{
title: '禁用词语出现',
description: '视频中出现「最好喝」「第一」等绝对化用语,可能违反广告法。',
timestamp: '0:42',
},
],
},
'task-004': {
title: 'AA数码新品发布',
platform: '抖音',
deadline: '2026-02-20',
statusHint: 'passed',
},
'task-005': {
title: 'BB运动饮料',
platform: '抖音',
deadline: '2026-02-12',
statusHint: 'pending_video',
},
}
const platformLabelMap: Record<string, string> = {
douyin: '抖音',
xiaohongshu: '小红书',
bilibili: 'B站',
kuaishou: '快手',
}
const getPlatformLabel = (platform?: string) => {
if (!platform) return '未知平台'
return platformLabelMap[platform] || platform
}
const deriveTaskStatus = (task: TaskResponse): TaskStatus => {
if (!task.has_script) {
return 'pending_script'
}
if (!task.has_video) {
return 'pending_video'
}
if (task.status === 'approved') {
return 'passed'
}
if (task.status === 'rejected' || task.status === 'failed') {
return 'need_revision'
}
if (task.status === 'pending' || task.status === 'processing') {
return 'ai_reviewing'
}
return 'agency_reviewing'
}
const getCurrentStep = (status: TaskStatus) => {
if (status === 'ai_reviewing' || status === 'need_revision') {
return 2
}
if (status === 'agency_reviewing') {
return 3
}
if (status === 'passed') {
return 4
}
return 1
}
const buildTaskDetail = (task: TaskResponse): TaskDetail => {
const profile = taskRequirementProfiles[task.task_id]
const status = deriveTaskStatus(task)
const platformLabel = profile?.platform || getPlatformLabel(task.platform)
return {
id: task.task_id,
title: profile?.title || `任务 ${task.task_id}`,
platform: platformLabel,
deadline: profile?.deadline || '待确认',
status,
currentStep: getCurrentStep(status),
progress: profile?.progress,
issues: profile?.issues,
reviewLogs: profile?.reviewLogs,
}
}
// 审核进度条组件
function ReviewProgressBar({ currentStep, status }: { currentStep: number; status: TaskStatus }) {
const steps = [
{ label: '已提交', step: 1 },
{ label: 'AI审核', step: 2 },
{ label: '代理商审核', step: 3 },
{ label: '最终结果', step: 4 },
]
return (
<div className="flex items-center w-full">
{steps.map((s, index) => {
const isCompleted = s.step < currentStep || (s.step === currentStep && status === 'passed')
const isCurrent = s.step === currentStep && status !== 'passed'
const isError = isCurrent && status === 'need_revision'
return (
<div key={s.step} className="flex items-center flex-1">
<div className="flex flex-col items-center gap-1 w-20">
<div className={cn(
'w-8 h-8 rounded-2xl flex items-center justify-center',
isCompleted ? 'bg-accent-green' :
isError ? 'bg-accent-coral' :
isCurrent ? 'bg-accent-indigo' :
'bg-bg-elevated border-[1.5px] border-border-subtle'
)}>
{isCompleted && <Check className="w-4 h-4 text-white" />}
{isCurrent && !isError && <Loader2 className="w-4 h-4 text-white animate-spin" />}
{isError && <X className="w-4 h-4 text-white" />}
</div>
<span className={cn(
'text-xs',
isCompleted ? 'text-text-secondary' :
isError ? 'text-accent-coral font-semibold' :
isCurrent ? 'text-accent-indigo font-semibold' :
'text-text-tertiary'
)}>
{s.label}
</span>
</div>
{index < steps.length - 1 && (
<div className={cn(
'h-0.5 flex-1',
s.step < currentStep || (s.step === currentStep && status === 'passed') ? 'bg-accent-green' : 'bg-border-subtle'
)} />
)}
</div>
)
})}
</div>
)
}
// 上传界面
function UploadView({ task }: { task: TaskDetail }) {
const [isDragging, setIsDragging] = useState(false)
const isScriptStep = task.status === 'pending_script'
const title = isScriptStep ? '上传脚本' : '上传视频'
const subtitle = isScriptStep
? '支持粘贴文本或上传文档'
: '支持 MP4/MOV 格式,≤ 100MB'
const actionLabel = isScriptStep ? '选择脚本文档' : '选择视频文件'
const hintText = isScriptStep ? '也可以直接粘贴脚本文本后提交' : '上传完成后将自动进入 AI 审核'
return (
<div className="flex flex-col gap-6 h-full">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<p className="text-sm text-text-tertiary">{subtitle}</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={cn(
'flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card',
isDragging ? 'border-accent-indigo bg-accent-indigo/5' : 'border-border-subtle'
)}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => { e.preventDefault(); setIsDragging(false) }}
>
<div className="w-20 h-20 rounded-[40px] bg-gradient-to-br from-accent-indigo to-[#4F46E5] opacity-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">{subtitle}</p>
</div>
<button
type="button"
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"
>
<Upload className="w-5 h-5" />
{actionLabel}
</button>
<p className="text-xs text-text-tertiary">{hintText}</p>
</div>
</div>
)
}
// AI 审核中界面
function ReviewingView({ task }: { task: TaskDetail }) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="bg-bg-card rounded-[20px] 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"> · 60-90</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" />
{/* 中心圆 */}
<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 </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>
{/* 日志区 */}
<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-[10px] bg-bg-page border border-border-subtle text-text-secondary text-[13px] font-medium"
>
<Bell className="w-4 h-4" />
</button>
</div>
</div>
)
}
// 审核结果界面
function ResultView({ task }: { task: TaskDetail }) {
const isNeedRevision = task.status === 'need_revision'
const isPassed = task.status === 'passed'
return (
<div className="flex flex-col gap-6 h-full">
{/* 审核流程进度 */}
<div className="bg-bg-card rounded-xl p-4 px-6 flex items-center card-shadow">
<div className="flex flex-col gap-1 w-[140px]">
<span className="text-sm font-semibold text-text-primary"></span>
<span className="text-xs text-text-tertiary"> 5</span>
</div>
<div className="flex-1">
<ReviewProgressBar currentStep={task.currentStep} status={task.status} />
</div>
</div>
{/* 状态横幅 */}
<div className={cn(
'flex items-center gap-3 px-6 py-4 rounded-xl',
isNeedRevision ? 'bg-accent-coral' : 'bg-accent-green'
)}>
{isNeedRevision ? (
<XCircle className="w-6 h-6 text-white" />
) : (
<CheckCircle className="w-6 h-6 text-white" />
)}
<div className="flex flex-col gap-0.5">
<span className="text-base font-semibold text-white">
{isNeedRevision ? '需要修改' : '审核通过'}
</span>
<span className="text-sm text-white/90">
{isNeedRevision
? `发现 ${task.issues?.length || 0} 处违规问题,请修改后重新提交`
: '恭喜!您的视频已通过所有审核'}
</span>
</div>
</div>
{/* 内容区 */}
<div className="flex gap-6 flex-1 min-h-0">
{/* 左侧:视频预览 */}
<div className="flex-1">
<div className="bg-bg-card rounded-2xl card-shadow h-full flex items-center justify-center">
<div className="w-[560px] h-[315px] rounded-xl bg-black flex items-center justify-center">
<div className="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center cursor-pointer hover:bg-white/30 transition-colors">
<Play className="w-8 h-8 text-white ml-1" />
</div>
</div>
</div>
</div>
{/* 右侧:问题清单 */}
{isNeedRevision && task.issues && task.issues.length > 0 && (
<div className="w-[420px]">
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex flex-col gap-4">
<h3 className="text-lg font-semibold text-text-primary"></h3>
<div className="flex flex-col gap-4">
{task.issues.map((issue, index) => (
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-accent-coral/15 text-accent-coral text-xs font-semibold">
</span>
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
</div>
{issue.timestamp && (
<button
type="button"
className="text-xs text-accent-indigo font-medium"
>
</button>
)}
</div>
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default function TaskDetailPage() {
const params = useParams()
const router = useRouter()
const taskId = params.id as string
const [isMobile, setIsMobile] = useState(true)
const [taskDetail, setTaskDetail] = useState<TaskDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
let isMounted = true
const fetchTask = async () => {
setIsLoading(true)
try {
const data = await api.getTask(taskId)
if (!isMounted) return
setTaskDetail(buildTaskDetail(data))
} catch (error) {
console.error('加载任务详情失败:', error)
if (isMounted) {
const fallbackProfile = taskRequirementProfiles[taskId]
if (fallbackProfile) {
const status = fallbackProfile.statusHint || 'pending_script'
setTaskDetail({
id: taskId,
title: fallbackProfile.title || `任务 ${taskId}`,
platform: fallbackProfile.platform || '未知平台',
deadline: fallbackProfile.deadline || '待确认',
status,
currentStep: getCurrentStep(status),
progress: fallbackProfile.progress,
issues: fallbackProfile.issues,
reviewLogs: fallbackProfile.reviewLogs,
})
}
}
} finally {
if (isMounted) {
setIsLoading(false)
}
}
}
if (taskId) {
fetchTask()
}
return () => {
isMounted = false
}
}, [taskId])
if (isLoading) {
return (
<DesktopLayout role="creator">
<div className="flex items-center justify-center h-full">
<p className="text-text-secondary">...</p>
</div>
</DesktopLayout>
)
}
if (!taskDetail) {
return (
<DesktopLayout role="creator">
<div className="flex items-center justify-center h-full">
<p className="text-text-secondary"></p>
</div>
</DesktopLayout>
)
}
// 根据状态获取页面标题
const getPageTitle = () => {
switch (taskDetail.status) {
case 'pending_script':
return '上传脚本'
case 'pending_video':
return '上传视频'
case 'ai_reviewing':
return 'AI 智能审核'
case 'agency_reviewing':
return '代理商审核中'
case 'need_revision':
case 'passed':
return '审核结果'
default:
return '任务详情'
}
}
// 根据状态渲染内容
const renderContent = () => {
switch (taskDetail.status) {
case 'pending_script':
case 'pending_video':
return <UploadView task={taskDetail} />
case 'ai_reviewing':
return <ReviewingView task={taskDetail} />
case 'need_revision':
case 'passed':
return <ResultView task={taskDetail} />
default:
return <div></div>
}
}
// 获取顶部操作按钮
const getTopActions = () => {
if (taskDetail.status === 'need_revision') {
return (
<div className="flex items-center gap-3">
<button
type="button"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-card border border-border-subtle text-text-secondary text-sm font-medium"
>
<MessageCircle className="w-[18px] h-[18px]" />
</button>
<button
type="button"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold"
>
<Upload className="w-[18px] h-[18px]" />
</button>
</div>
)
}
if (taskDetail.status === 'ai_reviewing') {
return (
<button
type="button"
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-card border border-border-subtle text-text-secondary text-sm font-medium"
>
<X className="w-[18px] h-[18px]" />
</button>
)
}
return null
}
// 桌面端内容
const DesktopContent = (
<DesktopLayout 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">
<h1 className="text-[28px] font-bold text-text-primary">{getPageTitle()}</h1>
<p className="text-[15px] text-text-secondary">
{taskDetail.title} · : {taskDetail.deadline}
</p>
</div>
<div className="flex items-center gap-3">
{getTopActions()}
{taskDetail.status === 'pending_video' && (
<div className="px-4 py-2 rounded-[10px] bg-accent-indigo/15">
<span className="text-sm font-semibold text-accent-indigo">{taskDetail.platform}</span>
</div>
)}
</div>
</div>
</div>
{/* 主内容 */}
<div className="flex-1 min-h-0">
{renderContent()}
</div>
</div>
</DesktopLayout>
)
// 移动端内容
const MobileContent = (
<MobileLayout role="creator">
<div className="flex flex-col gap-5 px-5 py-4 h-full">
{/* 头部 */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.back()}
className="w-10 h-10 rounded-full bg-bg-card flex items-center justify-center"
>
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{getPageTitle()}</h1>
<p className="text-sm text-text-secondary">{taskDetail.title}</p>
</div>
</div>
{/* 简化的移动端内容 */}
<div className="flex-1 flex items-center justify-center">
<p className="text-text-secondary"></p>
</div>
</div>
</MobileLayout>
)
return isMobile ? MobileContent : DesktopContent
}