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

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
}