主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
591 lines
19 KiB
TypeScript
591 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Check, Loader2, Video, Search, SlidersHorizontal, ChevronDown } from 'lucide-react'
|
|
import { MobileLayout } from '@/components/layout/MobileLayout'
|
|
import { DesktopLayout } from '@/components/layout/DesktopLayout'
|
|
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'
|
|
|
|
// 模拟任务数据
|
|
const seedTasks = [
|
|
{
|
|
id: 'task-001',
|
|
title: 'XX品牌618推广',
|
|
platform: '抖音',
|
|
description: '产品种草视频 · 时长要求 60-90秒',
|
|
deadline: '2026-02-10',
|
|
status: 'pending_script' as TaskStatus,
|
|
currentStep: 1, // 1-已提交, 2-AI审核, 3-代理商审核, 4-品牌终审
|
|
},
|
|
{
|
|
id: 'task-002',
|
|
title: 'YY美妆新品',
|
|
platform: '小红书',
|
|
description: '口播测评 · 视频已上传 · 等待AI审核',
|
|
submitTime: '今天 14:30',
|
|
status: 'ai_reviewing' as TaskStatus,
|
|
currentStep: 2,
|
|
progress: 65,
|
|
},
|
|
{
|
|
id: 'task-003',
|
|
title: 'ZZ饮品夏日',
|
|
platform: '抖音',
|
|
description: '探店Vlog · 发现2处问题',
|
|
reviewTime: '昨天 18:20',
|
|
status: 'need_revision' as TaskStatus,
|
|
currentStep: 2,
|
|
issueCount: 2,
|
|
},
|
|
{
|
|
id: 'task-004',
|
|
title: 'AA数码新品发布',
|
|
platform: '抖音',
|
|
description: '开箱测评 · 已发布',
|
|
status: 'passed' as TaskStatus,
|
|
currentStep: 4,
|
|
},
|
|
{
|
|
id: 'task-005',
|
|
title: 'BB运动饮料',
|
|
platform: '抖音',
|
|
description: '脚本已通过 · 待提交成片',
|
|
deadline: '2026-02-12',
|
|
status: 'pending_video' as TaskStatus,
|
|
currentStep: 1,
|
|
},
|
|
]
|
|
|
|
type UiTask = typeof seedTasks[number]
|
|
|
|
const taskProfiles = seedTasks.reduce<Record<string, UiTask>>((acc, task) => {
|
|
acc[task.id] = task
|
|
return acc
|
|
}, {})
|
|
|
|
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 getStatusDescription = (status: TaskStatus) => {
|
|
switch (status) {
|
|
case 'pending_script':
|
|
return '待提交脚本'
|
|
case 'pending_video':
|
|
return '待提交视频'
|
|
case 'ai_reviewing':
|
|
return 'AI 审核中'
|
|
case 'agency_reviewing':
|
|
return '待代理商审核'
|
|
case 'need_revision':
|
|
return '需修改后再提交'
|
|
case 'passed':
|
|
return '审核通过'
|
|
default:
|
|
return '任务进行中'
|
|
}
|
|
}
|
|
|
|
const mapApiTaskToUi = (task: TaskResponse): UiTask => {
|
|
const profile = taskProfiles[task.task_id]
|
|
const status = deriveTaskStatus(task)
|
|
const platformLabel = getPlatformLabel(task.platform)
|
|
const description = profile?.description || `${platformLabel} · ${getStatusDescription(status)}`
|
|
|
|
return {
|
|
id: task.task_id,
|
|
title: profile?.title || `任务 ${task.task_id}`,
|
|
platform: platformLabel,
|
|
description,
|
|
deadline: profile?.deadline,
|
|
submitTime: profile?.submitTime,
|
|
reviewTime: profile?.reviewTime,
|
|
status,
|
|
currentStep: profile?.currentStep || getCurrentStep(status),
|
|
progress: profile?.progress,
|
|
issueCount: profile?.issueCount,
|
|
}
|
|
}
|
|
|
|
// 状态徽章配置
|
|
function getStatusConfig(status: TaskStatus) {
|
|
switch (status) {
|
|
case 'pending_script':
|
|
return { label: '待上传', bg: 'bg-accent-blue/15', text: 'text-accent-blue' }
|
|
case 'pending_video':
|
|
return { label: '待上传', bg: 'bg-accent-blue/15', text: 'text-accent-blue' }
|
|
case 'ai_reviewing':
|
|
return { label: '审核中', bg: 'bg-accent-indigo/15', text: 'text-accent-indigo' }
|
|
case 'agency_reviewing':
|
|
return { label: '审核中', bg: 'bg-accent-amber/15', text: 'text-accent-amber' }
|
|
case 'need_revision':
|
|
return { label: '需修改', bg: 'bg-accent-coral/15', text: 'text-accent-coral' }
|
|
case 'passed':
|
|
return { label: '已通过', bg: 'bg-accent-green/15', text: 'text-accent-green' }
|
|
default:
|
|
return { label: '未知', bg: 'bg-bg-elevated', text: 'text-text-secondary' }
|
|
}
|
|
}
|
|
|
|
type StepState = 'done' | 'current' | 'pending' | 'error'
|
|
|
|
function getStepTimeline(status: TaskStatus): Array<{ label: string; state: StepState }> {
|
|
switch (status) {
|
|
case 'pending_script':
|
|
case 'pending_video':
|
|
return [
|
|
{ label: '待提交', state: 'current' },
|
|
{ label: 'AI审核', state: 'pending' },
|
|
{ label: '代理商', state: 'pending' },
|
|
{ label: '终审', state: 'pending' },
|
|
]
|
|
case 'ai_reviewing':
|
|
return [
|
|
{ label: '已提交', state: 'done' },
|
|
{ label: 'AI审核', state: 'current' },
|
|
{ label: '代理商', state: 'pending' },
|
|
{ label: '终审', state: 'pending' },
|
|
]
|
|
case 'need_revision':
|
|
return [
|
|
{ label: '已提交', state: 'done' },
|
|
{ label: 'AI未通过', state: 'error' },
|
|
{ label: '代理商', state: 'pending' },
|
|
{ label: '终审', state: 'pending' },
|
|
]
|
|
case 'agency_reviewing':
|
|
return [
|
|
{ label: '已提交', state: 'done' },
|
|
{ label: 'AI通过', state: 'done' },
|
|
{ label: '代理商审核', state: 'current' },
|
|
{ label: '终审', state: 'pending' },
|
|
]
|
|
case 'passed':
|
|
return [
|
|
{ label: '已提交', state: 'done' },
|
|
{ label: 'AI通过', state: 'done' },
|
|
{ label: '代理商通过', state: 'done' },
|
|
{ label: '已通过', state: 'done' },
|
|
]
|
|
default:
|
|
return [
|
|
{ label: '已提交', state: 'pending' },
|
|
{ label: 'AI审核', state: 'pending' },
|
|
{ label: '代理商', state: 'pending' },
|
|
{ label: '终审', state: 'pending' },
|
|
]
|
|
}
|
|
}
|
|
|
|
function TaskStepSummary({ status }: { status: TaskStatus }) {
|
|
const steps = getStepTimeline(status)
|
|
|
|
const stateStyle = (state: StepState) => {
|
|
if (state === 'done') return 'bg-accent-green text-text-secondary'
|
|
if (state === 'current') return 'bg-accent-indigo text-accent-indigo'
|
|
if (state === 'error') return 'bg-accent-coral text-accent-coral'
|
|
return 'bg-border-subtle text-text-tertiary'
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
{steps.map((step, index) => (
|
|
<div key={`${step.label}-${index}`} className="flex items-center gap-1">
|
|
<span className={cn('w-1.5 h-1.5 rounded-full', stateStyle(step.state))} />
|
|
<span className={cn('text-[11px]', step.state === 'error' ? 'text-accent-coral' : 'text-text-tertiary')}>
|
|
{step.label}
|
|
</span>
|
|
{index < steps.length - 1 && <span className="text-text-tertiary">·</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 审核进度条组件
|
|
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 py-2">
|
|
{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-[70px]">
|
|
<div className={cn(
|
|
'w-7 h-7 rounded-full 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-3.5 h-3.5 text-white" />}
|
|
{isCurrent && !isError && <Loader2 className="w-3.5 h-3.5 text-white animate-spin" />}
|
|
{isError && <span className="w-2 h-2 bg-white rounded-full" />}
|
|
</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 ? 'bg-accent-green' : 'bg-border-subtle'
|
|
)} />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 桌面端任务卡片
|
|
function DesktopTaskCard({ task, onClick }: { task: UiTask; onClick: () => void }) {
|
|
const config = getStatusConfig(task.status)
|
|
const showProgress = ['ai_reviewing', 'agency_reviewing', 'need_revision'].includes(task.status)
|
|
|
|
const getActionButton = () => {
|
|
if (task.status === 'pending_script' || task.status === 'pending_video') {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="px-5 py-2.5 rounded-[10px] bg-accent-green text-white text-sm font-semibold"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
上传{task.status === 'pending_script' ? '脚本' : '视频'}
|
|
</button>
|
|
)
|
|
}
|
|
if (task.status === 'ai_reviewing') {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="px-5 py-2.5 rounded-[10px] bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
查看详情
|
|
</button>
|
|
)
|
|
}
|
|
if (task.status === 'need_revision') {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="px-5 py-2.5 rounded-[10px] bg-accent-coral text-white text-sm font-semibold"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
查看修改
|
|
</button>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="px-5 py-2.5 rounded-[10px] bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
查看详情
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="bg-bg-card rounded-2xl p-5 flex flex-col gap-4 card-shadow cursor-pointer hover:bg-bg-elevated/50 transition-colors"
|
|
onClick={onClick}
|
|
>
|
|
{/* 任务主行 */}
|
|
<div className="flex items-center justify-between">
|
|
{/* 左侧:缩略图 + 信息 */}
|
|
<div className="flex items-center gap-4">
|
|
{/* 缩略图占位 */}
|
|
<div className="w-20 h-[60px] rounded-lg bg-[#1A1A1E] flex items-center justify-center flex-shrink-0">
|
|
<Video className="w-6 h-6 text-text-tertiary" />
|
|
</div>
|
|
{/* 任务信息 */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-base font-semibold text-text-primary">{task.title}</span>
|
|
<span className="text-[13px] text-text-secondary">{task.description}</span>
|
|
<TaskStepSummary status={task.status} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右侧:状态 + 操作按钮 */}
|
|
<div className="flex items-center gap-4">
|
|
<span className={cn('px-3 py-1.5 rounded-lg text-[13px] font-semibold', config.bg, config.text)}>
|
|
{config.label}
|
|
</span>
|
|
{getActionButton()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 审核进度条 */}
|
|
{showProgress && <ReviewProgressBar currentStep={task.currentStep} status={task.status} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 移动端任务卡片
|
|
function MobileTaskCard({ task, onClick }: { task: UiTask; onClick: () => void }) {
|
|
const config = getStatusConfig(task.status)
|
|
const showProgress = ['ai_reviewing', 'agency_reviewing', 'need_revision'].includes(task.status)
|
|
|
|
return (
|
|
<div
|
|
className="bg-bg-card rounded-xl p-4 flex flex-col gap-3 card-shadow cursor-pointer"
|
|
onClick={onClick}
|
|
>
|
|
{/* 头部 */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[17px] font-semibold text-text-primary">{task.title}</span>
|
|
<span className={cn('px-2.5 py-1 rounded-lg text-xs font-semibold', config.bg, config.text)}>
|
|
{config.label}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 进度条 */}
|
|
{showProgress && (
|
|
<div className="py-1">
|
|
<ReviewProgressBar currentStep={task.currentStep} status={task.status} />
|
|
</div>
|
|
)}
|
|
|
|
{/* 描述 */}
|
|
<p className="text-sm text-text-secondary">{task.description}</p>
|
|
<TaskStepSummary status={task.status} />
|
|
|
|
{/* 底部 */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[13px] text-text-tertiary">
|
|
{task.deadline && `截止: ${task.deadline}`}
|
|
{task.submitTime && `提交于: ${task.submitTime}`}
|
|
{task.reviewTime && `审核于: ${task.reviewTime}`}
|
|
</span>
|
|
{(task.status === 'pending_script' || task.status === 'pending_video') && (
|
|
<button
|
|
type="button"
|
|
className="px-4 py-2 rounded-[10px] bg-accent-green text-white text-sm font-semibold"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
去上传
|
|
</button>
|
|
)}
|
|
{task.status === 'need_revision' && (
|
|
<button
|
|
type="button"
|
|
className="px-4 py-2 rounded-[10px] bg-accent-coral text-white text-sm font-semibold"
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
查看修改
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function CreatorTasksPage() {
|
|
const router = useRouter()
|
|
const [isMobile, setIsMobile] = useState(true)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [tasks, setTasks] = useState<UiTask[]>(seedTasks)
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
|
|
checkMobile()
|
|
window.addEventListener('resize', checkMobile)
|
|
return () => window.removeEventListener('resize', checkMobile)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let isMounted = true
|
|
|
|
const fetchTasks = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const data = await api.listTasks()
|
|
if (!isMounted) return
|
|
const mapped = data.items.map(mapApiTaskToUi)
|
|
setTasks(mapped)
|
|
} catch (error) {
|
|
console.error('加载任务失败:', error)
|
|
} finally {
|
|
if (isMounted) {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchTasks()
|
|
|
|
return () => {
|
|
isMounted = false
|
|
}
|
|
}, [])
|
|
|
|
const pendingCount = tasks.filter(t =>
|
|
!['passed'].includes(t.status)
|
|
).length
|
|
|
|
const handleTaskClick = (taskId: string) => {
|
|
router.push(`/creator/task/${taskId}`)
|
|
}
|
|
|
|
// 桌面端内容
|
|
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">我的任务</h1>
|
|
<p className="text-[15px] text-text-secondary">共 {pendingCount} 个进行中任务</p>
|
|
</div>
|
|
{/* 搜索和筛选 */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle">
|
|
<Search className="w-[18px] h-[18px] text-text-secondary" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索任务..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="bg-transparent text-sm text-text-primary placeholder-text-tertiary focus:outline-none w-32"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-bg-card rounded-xl border border-border-subtle text-text-secondary text-sm font-medium"
|
|
>
|
|
<SlidersHorizontal className="w-[18px] h-[18px]" />
|
|
<span>全部状态</span>
|
|
<ChevronDown className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 任务列表 */}
|
|
<div className="flex flex-col gap-4 flex-1 overflow-auto">
|
|
{isLoading && (
|
|
<div className="bg-bg-card rounded-2xl p-6 text-sm text-text-tertiary">
|
|
正在加载任务...
|
|
</div>
|
|
)}
|
|
{!isLoading && tasks.length === 0 && (
|
|
<div className="bg-bg-card rounded-2xl p-6 text-sm text-text-tertiary">
|
|
暂无任务
|
|
</div>
|
|
)}
|
|
{tasks.map((task) => (
|
|
<DesktopTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onClick={() => handleTaskClick(task.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</DesktopLayout>
|
|
)
|
|
|
|
// 移动端内容
|
|
const MobileContent = (
|
|
<MobileLayout role="creator">
|
|
<div className="flex flex-col gap-5 px-5 py-4">
|
|
{/* 头部 */}
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-[26px] font-bold text-text-primary">我的任务</h1>
|
|
<p className="text-sm text-text-secondary">共 {pendingCount} 个进行中任务</p>
|
|
</div>
|
|
|
|
{/* 任务列表 */}
|
|
<div className="flex flex-col gap-3">
|
|
{isLoading && (
|
|
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
|
正在加载任务...
|
|
</div>
|
|
)}
|
|
{!isLoading && tasks.length === 0 && (
|
|
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
|
|
暂无任务
|
|
</div>
|
|
)}
|
|
{tasks.map((task) => (
|
|
<MobileTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onClick={() => handleTaskClick(task.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</MobileLayout>
|
|
)
|
|
|
|
return isMobile ? MobileContent : DesktopContent
|
|
}
|