feat: 完善代理商端业务逻辑与前后端框架

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+635
View File
@@ -0,0 +1,635 @@
'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
}