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
+11
View File
@@ -0,0 +1,11 @@
'use client'
import { AuthGuard } from '@/components/auth/AuthGuard'
export default function CreatorLayout({
children,
}: {
children: React.ReactNode
}) {
return <AuthGuard allowedRoles={['creator']}>{children}</AuthGuard>
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Bell, CheckCircle, XCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react'
import { DesktopLayout } from '@/components/layout/DesktopLayout'
import { MobileLayout } from '@/components/layout/MobileLayout'
import { cn } from '@/lib/utils'
type MessageStatus = 'info' | 'success' | 'error'
const mockMessages = [
{
id: 'msg-001',
taskId: 'task-002',
title: 'AI 审核通过',
description: '已进入代理商审核,请等待最终结果。',
time: '刚刚',
status: 'success' as MessageStatus,
read: false,
},
{
id: 'msg-002',
taskId: 'task-003',
title: 'AI 审核未通过',
description: '检测到竞品 Logo 与绝对化用语,请修改后重新提交。',
time: '10 分钟前',
status: 'error' as MessageStatus,
read: false,
},
{
id: 'msg-003',
taskId: 'task-001',
title: '等待提交脚本',
description: '请先上传脚本,系统才能开始合规预审。',
time: '1 小时前',
status: 'info' as MessageStatus,
read: true,
},
]
const statusConfig: Record<MessageStatus, { icon: React.ElementType; color: string; bg: string }> = {
success: { icon: CheckCircle, color: 'text-accent-green', bg: 'bg-accent-green/15' },
error: { icon: XCircle, color: 'text-accent-coral', bg: 'bg-accent-coral/15' },
info: { icon: Clock, color: 'text-accent-indigo', bg: 'bg-accent-indigo/15' },
}
function MessageCard({
title,
description,
time,
status,
isRead,
onClick,
}: {
title: string
description: string
time: string
status: MessageStatus
isRead: boolean
onClick: () => void
}) {
const Icon = statusConfig[status].icon
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left bg-bg-card rounded-xl p-4 flex items-start gap-4 card-shadow hover:bg-bg-elevated/50 transition-colors',
!isRead && 'ring-1 ring-accent-indigo/20'
)}
>
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center', statusConfig[status].bg)}>
<Icon className={cn('w-5 h-5', statusConfig[status].color)} />
</div>
<div className="flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('text-sm', isRead ? 'text-text-primary' : 'text-text-primary font-semibold')}>
{title}
</span>
{!isRead && <span className="w-2 h-2 rounded-full bg-accent-indigo" />}
</div>
<span className="text-xs text-text-tertiary">{time}</span>
</div>
<p className="text-sm text-text-secondary">{description}</p>
</div>
</button>
)
}
export default function CreatorMessagesPage() {
const router = useRouter()
const [isMobile, setIsMobile] = useState(true)
const [messages, setMessages] = useState(mockMessages)
const [showRead, setShowRead] = useState(false)
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
const handleClick = (messageId: string, taskId: string) => {
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, read: true } : msg))
)
router.push(`/creator/task/${taskId}`)
}
const unreadCount = messages.filter((msg) => !msg.read).length
const unreadMessages = messages.filter((msg) => !msg.read)
const readMessages = messages.filter((msg) => msg.read)
const DesktopContent = (
<DesktopLayout role="creator">
<div className="flex flex-col gap-6 h-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
<Bell className="w-6 h-6 text-accent-indigo" />
</div>
<div>
<h1 className="text-[28px] font-bold text-text-primary"></h1>
<p className="text-[15px] text-text-secondary"> AI </p>
</div>
</div>
<span className="text-sm text-text-tertiary"> {unreadCount}</span>
</div>
<div className="flex flex-col gap-4">
{unreadMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{unreadMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
<div className="pt-2">
<button
type="button"
onClick={() => setShowRead((prev) => !prev)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary"
>
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
({readMessages.length})
</button>
</div>
{showRead && readMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{showRead && readMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
</div>
</div>
</DesktopLayout>
)
const MobileContent = (
<MobileLayout role="creator">
<div className="flex flex-col gap-5 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
<Bell className="w-5 h-5 text-accent-indigo" />
</div>
<div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary"></p>
</div>
</div>
<div className="flex flex-col gap-3">
{unreadMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{unreadMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
<button
type="button"
onClick={() => setShowRead((prev) => !prev)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary pt-1"
>
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
({readMessages.length})
</button>
{showRead && readMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{showRead && readMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
</div>
</div>
</MobileLayout>
)
return isMobile ? MobileContent : DesktopContent
}
+590
View File
@@ -0,0 +1,590 @@
'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
}
+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
}