- 后端 TaskResponse.ProjectInfo 新增 platform 字段 - 修复代理商 6 个页面硬编码 platform='douyin' 的问题,改为读取实际值 - Brief 预览弹窗:占位符改为 iframe/img 实际展示文件内容 - PDF 用 iframe 在线预览 - 图片直接展示 - 其他类型提示下载 - Brief 下载:改用 a 标签触发下载 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
Video,
|
|
Search,
|
|
SlidersHorizontal,
|
|
ChevronDown,
|
|
Upload,
|
|
Bot,
|
|
Users,
|
|
Building2,
|
|
Check,
|
|
X,
|
|
Loader2
|
|
} from 'lucide-react'
|
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
|
import { cn } from '@/lib/utils'
|
|
import { getPlatformInfo } from '@/lib/platforms'
|
|
import { api } from '@/lib/api'
|
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
import { useSSE } from '@/contexts/SSEContext'
|
|
import { mapTaskToUI, type StepStatus, type StageSteps } from '@/lib/taskStageMapper'
|
|
import type { TaskResponse } from '@/types/task'
|
|
|
|
// UI 用任务数据(从 API 数据映射而来)
|
|
type Task = {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
platform: string
|
|
scriptStage: StageSteps
|
|
videoStage: StageSteps
|
|
buttonText: string
|
|
buttonType: 'upload' | 'view' | 'fix'
|
|
scriptColor: string
|
|
videoColor: string
|
|
filterCategory: 'pending' | 'reviewing' | 'rejected' | 'completed'
|
|
}
|
|
|
|
// Mock 数据(开发模式使用)
|
|
const mockTasks: Task[] = [
|
|
{
|
|
id: 'task-001', title: 'XX品牌618推广', description: '产品种草视频 · 时长要求 60-90秒 · 截止: 2026-02-10', platform: 'douyin',
|
|
scriptStage: { submit: 'current', ai: 'pending', agency: 'pending', brand: 'pending' },
|
|
videoStage: { submit: 'pending', ai: 'pending', agency: 'pending', brand: 'pending' },
|
|
buttonText: '上传脚本', buttonType: 'upload', scriptColor: 'blue', videoColor: 'tertiary', filterCategory: 'pending',
|
|
},
|
|
{
|
|
id: 'task-002', title: 'YY美妆新品', description: '口播测评 · 已上传视频 · 提交于: 今天 14:30', platform: 'xiaohongshu',
|
|
scriptStage: { submit: 'done', ai: 'current', agency: 'pending', brand: 'pending' },
|
|
videoStage: { submit: 'pending', ai: 'pending', agency: 'pending', brand: 'pending' },
|
|
buttonText: '查看详情', buttonType: 'view', scriptColor: 'indigo', videoColor: 'tertiary', filterCategory: 'reviewing',
|
|
},
|
|
{
|
|
id: 'task-003', title: 'ZZ饮品夏日', description: '探店Vlog · 发现2处问题 · 需修改后重新提交', platform: 'bilibili',
|
|
scriptStage: { submit: 'done', ai: 'error', agency: 'pending', brand: 'pending' },
|
|
videoStage: { submit: 'pending', ai: 'pending', agency: 'pending', brand: 'pending' },
|
|
buttonText: '查看修改', buttonType: 'fix', scriptColor: 'coral', videoColor: 'tertiary', filterCategory: 'rejected',
|
|
},
|
|
{
|
|
id: 'task-004', title: 'AA数码新品发布', description: '开箱测评 · 审核通过 · 可发布', platform: 'douyin',
|
|
scriptStage: { submit: 'done', ai: 'done', agency: 'done', brand: 'done' },
|
|
videoStage: { submit: 'done', ai: 'done', agency: 'done', brand: 'done' },
|
|
buttonText: '查看详情', buttonType: 'view', scriptColor: 'green', videoColor: 'green', filterCategory: 'completed',
|
|
},
|
|
{
|
|
id: 'task-008', title: 'EE食品试吃', description: '美食测评 · 脚本通过 · 待上传视频', platform: 'douyin',
|
|
scriptStage: { submit: 'done', ai: 'done', agency: 'done', brand: 'done' },
|
|
videoStage: { submit: 'current', ai: 'pending', agency: 'pending', brand: 'pending' },
|
|
buttonText: '上传视频', buttonType: 'upload', scriptColor: 'green', videoColor: 'blue', filterCategory: 'pending',
|
|
},
|
|
]
|
|
|
|
function mapTaskResponseToUI(task: TaskResponse): Task {
|
|
const ui = mapTaskToUI(task)
|
|
const buttonTypeMap: Record<string, 'upload' | 'view' | 'fix'> = {
|
|
primary: 'upload', success: 'view', warning: 'fix', disabled: 'view',
|
|
}
|
|
return {
|
|
id: task.id,
|
|
title: task.name,
|
|
description: `${task.project.name} · ${ui.statusLabel}`,
|
|
platform: task.project?.platform || 'douyin',
|
|
scriptStage: ui.scriptStage,
|
|
videoStage: ui.videoStage,
|
|
buttonText: ui.buttonText,
|
|
buttonType: buttonTypeMap[ui.buttonType] || 'view',
|
|
scriptColor: ui.scriptColor,
|
|
videoColor: ui.videoColor,
|
|
filterCategory: ui.filterCategory,
|
|
}
|
|
}
|
|
|
|
// 步骤图标组件
|
|
function StepIcon({ status, icon }: { status: StepStatus; icon: 'upload' | 'bot' | 'users' | 'building' }) {
|
|
const IconComponent = {
|
|
upload: Upload,
|
|
bot: Bot,
|
|
users: Users,
|
|
building: Building2,
|
|
}[icon]
|
|
|
|
const getStyle = () => {
|
|
switch (status) {
|
|
case 'done':
|
|
return 'bg-accent-green'
|
|
case 'current':
|
|
return 'bg-accent-indigo'
|
|
case 'error':
|
|
return 'bg-accent-coral'
|
|
default:
|
|
return 'bg-bg-elevated border-[1.5px] border-border-subtle'
|
|
}
|
|
}
|
|
|
|
const getIconColor = () => {
|
|
if (status === 'done' || status === 'current' || status === 'error') return 'text-white'
|
|
return 'text-text-tertiary'
|
|
}
|
|
|
|
return (
|
|
<div className={cn('w-7 h-7 rounded-full flex items-center justify-center', getStyle())}>
|
|
{status === 'done' && <Check size={14} className={getIconColor()} />}
|
|
{status === 'current' && <Loader2 size={14} className={cn(getIconColor(), 'animate-spin')} />}
|
|
{status === 'error' && <X size={14} className={getIconColor()} />}
|
|
{status === 'pending' && <IconComponent size={14} className={getIconColor()} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 进度条组件
|
|
function ProgressBar({ stage, color }: {
|
|
stage: StageSteps
|
|
color: string
|
|
}) {
|
|
const steps = [
|
|
{ key: 'submit', label: '提交', icon: 'upload' as const, status: stage.submit },
|
|
{ key: 'ai', label: 'AI审核', icon: 'bot' as const, status: stage.ai },
|
|
{ key: 'agency', label: '代理商', icon: 'users' as const, status: stage.agency },
|
|
{ key: 'brand', label: '品牌', icon: 'building' as const, status: stage.brand },
|
|
]
|
|
|
|
const getLineColor = (fromStatus: StepStatus) => {
|
|
if (fromStatus === 'done') return 'bg-accent-green'
|
|
return 'bg-border-subtle'
|
|
}
|
|
|
|
const getLabelColor = (status: StepStatus) => {
|
|
if (status === 'done') return 'text-text-secondary'
|
|
if (status === 'current') return 'text-accent-indigo font-semibold'
|
|
if (status === 'error') return 'text-accent-coral font-semibold'
|
|
return 'text-text-tertiary'
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center w-full">
|
|
{steps.map((step, index) => (
|
|
<div key={step.key} className="flex items-center flex-1">
|
|
<div className="flex flex-col items-center gap-1.5 w-14">
|
|
<StepIcon status={step.status} icon={step.icon} />
|
|
<span className={cn('text-[10px]', getLabelColor(step.status))}>{step.label}</span>
|
|
</div>
|
|
{index < steps.length - 1 && (
|
|
<div className={cn('h-0.5 flex-1', getLineColor(step.status))} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 任务卡片组件
|
|
function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
|
const platform = getPlatformInfo(task.platform)
|
|
|
|
const getStageColor = (color: string) => {
|
|
switch (color) {
|
|
case 'blue': return 'text-accent-blue'
|
|
case 'indigo': return 'text-accent-indigo'
|
|
case 'coral': return 'text-accent-coral'
|
|
case 'green': return 'text-accent-green'
|
|
case 'red': return 'text-accent-coral'
|
|
default: return 'text-text-tertiary'
|
|
}
|
|
}
|
|
|
|
const getButtonStyle = () => {
|
|
switch (task.buttonType) {
|
|
case 'upload':
|
|
return 'bg-accent-green text-white'
|
|
case 'fix':
|
|
return 'bg-accent-coral text-white'
|
|
default:
|
|
return 'bg-transparent border-[1.5px] border-accent-indigo text-accent-indigo'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="bg-bg-card rounded-2xl overflow-hidden card-shadow cursor-pointer hover:bg-bg-elevated/30 transition-colors"
|
|
onClick={onClick}
|
|
>
|
|
{platform && (
|
|
<div className={`px-5 py-2 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-2`}>
|
|
<span className="text-base">{platform.icon}</span>
|
|
<span className={`text-sm font-medium ${platform.textColor}`}>{platform.name}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-5 flex flex-col gap-4">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={cn('px-5 py-2.5 rounded-[10px] text-sm font-semibold', getButtonStyle())}
|
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
>
|
|
{task.buttonText}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 pt-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('text-xs font-semibold w-8', getStageColor(task.scriptColor))}>脚本</span>
|
|
<div className="flex-1">
|
|
<ProgressBar stage={task.scriptStage} color={task.scriptColor} />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('text-xs font-semibold w-8', getStageColor(task.videoColor))}>视频</span>
|
|
<div className="flex-1">
|
|
<ProgressBar stage={task.videoStage} color={task.videoColor} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TaskFilter = 'all' | 'pending' | 'reviewing' | 'rejected' | 'completed'
|
|
|
|
const filterOptions: { value: TaskFilter; label: string }[] = [
|
|
{ value: 'all', label: '全部状态' },
|
|
{ value: 'pending', label: '待提交' },
|
|
{ value: 'reviewing', label: '审核中' },
|
|
{ value: 'rejected', label: '已驳回' },
|
|
{ value: 'completed', label: '已完成' },
|
|
]
|
|
|
|
// 骨架屏
|
|
function TaskSkeleton() {
|
|
return (
|
|
<div className="bg-bg-card rounded-2xl overflow-hidden card-shadow animate-pulse">
|
|
<div className="px-5 py-2 bg-bg-elevated border-b border-border-subtle">
|
|
<div className="h-4 w-20 bg-bg-page rounded" />
|
|
</div>
|
|
<div className="p-5 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-20 h-[60px] rounded-lg bg-bg-elevated" />
|
|
<div className="flex flex-col gap-2">
|
|
<div className="h-4 w-32 bg-bg-elevated rounded" />
|
|
<div className="h-3 w-48 bg-bg-elevated rounded" />
|
|
</div>
|
|
</div>
|
|
<div className="h-10 w-24 bg-bg-elevated rounded-[10px]" />
|
|
</div>
|
|
<div className="flex flex-col gap-3 pt-3">
|
|
<div className="h-8 bg-bg-elevated rounded" />
|
|
<div className="h-8 bg-bg-elevated rounded" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function CreatorTasksPage() {
|
|
const router = useRouter()
|
|
const { subscribe } = useSSE()
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [filter, setFilter] = useState<TaskFilter>('all')
|
|
const [showFilterDropdown, setShowFilterDropdown] = useState(false)
|
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [total, setTotal] = useState(0)
|
|
|
|
const loadTasks = useCallback(async () => {
|
|
if (USE_MOCK) {
|
|
setTasks(mockTasks)
|
|
setTotal(mockTasks.length)
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true)
|
|
const response = await api.listTasks(1, 50)
|
|
const mapped = response.items.map(mapTaskResponseToUI)
|
|
setTasks(mapped)
|
|
setTotal(response.total)
|
|
} catch (err) {
|
|
console.error('加载任务失败:', err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadTasks()
|
|
}, [loadTasks])
|
|
|
|
// SSE 实时更新
|
|
useEffect(() => {
|
|
const unsub1 = subscribe('task_updated', () => { loadTasks() })
|
|
const unsub2 = subscribe('new_task', () => { loadTasks() })
|
|
return () => { unsub1(); unsub2() }
|
|
}, [subscribe, loadTasks])
|
|
|
|
const handleTaskClick = (taskId: string) => {
|
|
router.push(`/creator/task/${taskId}`)
|
|
}
|
|
|
|
const filteredTasks = tasks.filter(task => {
|
|
const matchesSearch = searchQuery === '' ||
|
|
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
task.description.toLowerCase().includes(searchQuery.toLowerCase())
|
|
|
|
const matchesFilter = filter === 'all' || task.filterCategory === filter
|
|
|
|
return matchesSearch && matchesFilter
|
|
})
|
|
|
|
const currentFilterLabel = filterOptions.find(opt => opt.value === filter)?.label || '全部状态'
|
|
|
|
return (
|
|
<ResponsiveLayout role="creator">
|
|
<div className="flex flex-col gap-6 h-full">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-2xl lg:text-[28px] font-bold text-text-primary">我的任务</h1>
|
|
<p className="text-sm lg:text-[15px] text-text-secondary">
|
|
{filter === 'all' ? `共 ${total} 个任务` : `${currentFilterLabel} ${filteredTasks.length} 个`}
|
|
</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>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
|
|
className={cn(
|
|
'flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors',
|
|
filter !== 'all'
|
|
? 'bg-accent-indigo/10 border-accent-indigo text-accent-indigo'
|
|
: 'bg-bg-card border-border-subtle text-text-secondary'
|
|
)}
|
|
>
|
|
<SlidersHorizontal className="w-[18px] h-[18px]" />
|
|
<span>{currentFilterLabel}</span>
|
|
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilterDropdown && 'rotate-180')} />
|
|
</button>
|
|
{showFilterDropdown && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setShowFilterDropdown(false)}
|
|
/>
|
|
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card rounded-xl border border-border-subtle card-shadow z-20 overflow-hidden">
|
|
{filterOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setFilter(option.value)
|
|
setShowFilterDropdown(false)
|
|
}}
|
|
className={cn(
|
|
'w-full px-4 py-3 text-left text-sm transition-colors',
|
|
filter === option.value
|
|
? 'bg-accent-indigo/10 text-accent-indigo font-medium'
|
|
: 'text-text-primary hover:bg-bg-elevated'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 flex-1 overflow-y-auto pr-2">
|
|
{isLoading ? (
|
|
<>
|
|
<TaskSkeleton />
|
|
<TaskSkeleton />
|
|
<TaskSkeleton />
|
|
</>
|
|
) : filteredTasks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<Search className="w-12 h-12 text-text-tertiary mb-4" />
|
|
<p className="text-text-secondary">没有找到匹配的任务</p>
|
|
<p className="text-sm text-text-tertiary mt-1">尝试调整搜索条件或筛选状态</p>
|
|
</div>
|
|
) : (
|
|
filteredTasks.map((task) => (
|
|
<TaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onClick={() => handleTaskClick(task.id)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResponsiveLayout>
|
|
)
|
|
}
|