feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user