Your Name 54eaa54966 feat: 前端全面对接后端 API(Phase 1 完成)
- 新增基础设施:useOSSUpload Hook、SSEContext Provider、taskStageMapper 工具
- 达人端4页面:任务列表/详情/脚本上传/视频上传对接真实 API
- 代理商端3页面:工作台/审核队列/审核详情对接真实 API
- 品牌方端4页面:项目列表/创建项目/项目详情/Brief配置对接真实 API
- 保留 USE_MOCK 开关,mock 模式下使用类型安全的 mock 数据
- 所有页面添加 loading 骨架屏、SSE 实时更新、错误处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:47 +08:00

574 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import {
FileText,
Video,
Search,
Clock,
Eye,
File,
Download,
MessageSquareWarning,
Loader2
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse } from '@/types/task'
// ==================== Mock 数据 ====================
const mockScriptTasks: TaskResponse[] = [
{
id: 'script-001', name: '夏日护肤推广脚本', sequence: 1,
stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-001', name: '小美护肤' },
script_file_name: '夏日护肤推广_脚本v2.docx',
script_ai_score: 88,
script_ai_result: { score: 88, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T14:30:00Z', updated_at: '2026-02-06T14:30:00Z',
},
{
id: 'script-002', name: '新品口红试色脚本', sequence: 2,
stage: 'script_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-002', name: '美妆Lisa' },
script_file_name: '口红试色_脚本v1.docx',
script_ai_score: 72,
script_ai_result: { score: 72, violations: [{ type: '违禁词', content: '最好', severity: 'medium', suggestion: '替换' }], soft_warnings: [] },
appeal_count: 1, is_appeal: true, appeal_reason: '已修改违规用词,请求重新审核',
created_at: '2026-02-06T12:15:00Z', updated_at: '2026-02-06T12:15:00Z',
},
{
id: 'script-003', name: '健身器材推荐脚本', sequence: 3,
stage: 'script_agency_review',
project: { id: 'proj-002', name: 'XX运动品牌', brand_name: 'XX运动' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-003', name: '健身教练王' },
script_file_name: '健身器材_推荐脚本.pdf',
script_ai_score: 95,
script_ai_result: { score: 95, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
},
]
const mockVideoTasks: TaskResponse[] = [
{
id: 'video-001', name: '夏日护肤推广', sequence: 1,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-001', name: '小美护肤' },
video_file_name: '夏日护肤_成片v2.mp4',
video_duration: 135, video_ai_score: 85,
video_ai_result: { score: 85, violations: [], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T15:00:00Z', updated_at: '2026-02-06T15:00:00Z',
},
{
id: 'video-002', name: '新品口红试色', sequence: 2,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-002', name: '美妆Lisa' },
video_file_name: '口红试色_终版.mp4',
video_duration: 222, video_ai_score: 68,
video_ai_result: { score: 68, violations: [{ type: '竞品', content: '疑似竞品', severity: 'high', suggestion: '确认' }], soft_warnings: [] },
appeal_count: 1, is_appeal: true, appeal_reason: '已按要求重新剪辑,删除了争议片段',
created_at: '2026-02-06T13:45:00Z', updated_at: '2026-02-06T13:45:00Z',
},
{
id: 'video-003', name: '美妆新品体验', sequence: 3,
stage: 'video_agency_review',
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX品牌' },
agency: { id: 'ag-001', name: '优创代理' },
creator: { id: 'cr-003', name: '达人C' },
video_file_name: '美妆体验_v3.mp4',
video_duration: 260, video_ai_score: 58,
video_ai_result: { score: 58, violations: [{ type: '违禁词', content: '最好', severity: 'high', suggestion: '替换' }], soft_warnings: [] },
appeal_count: 0, is_appeal: false,
created_at: '2026-02-06T11:30:00Z', updated_at: '2026-02-06T11:30:00Z',
},
]
// ==================== 工具函数 ====================
function getRiskLevel(task: TaskResponse, type: 'script' | 'video'): 'low' | 'medium' | 'high' {
const score = type === 'script' ? task.script_ai_score : task.video_ai_score
if (score == null) return 'low'
if (score >= 85) return 'low'
if (score >= 70) return 'medium'
return 'high'
}
const riskLevelConfig = {
low: { label: 'AI通过', color: 'bg-accent-green', textColor: 'text-accent-green' },
medium: { label: '风险:中', color: 'bg-accent-amber', textColor: 'text-accent-amber' },
high: { label: '风险:高', color: 'bg-accent-coral', textColor: 'text-accent-coral' },
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
function ScoreTag({ score }: { score: number }) {
if (score >= 85) return <SuccessTag>{score}</SuccessTag>
if (score >= 70) return <WarningTag>{score}</WarningTag>
return <ErrorTag>{score}</ErrorTag>
}
// ==================== 卡片组件 ====================
function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType<typeof useToast> }) {
const riskLevel = getRiskLevel(task, 'script')
const riskConfig = riskLevelConfig[riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.script_file_name || '脚本文件'}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
{/* 顶部条 */}
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || task.project.name}</span>
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
</div>
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</div>
)}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.script_file_name || '脚本文件'}</p>
</div>
<button type="button" onClick={handlePreview} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="预览文件">
<Eye size={18} className="text-text-secondary" />
</button>
<button type="button" onClick={handleDownload} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="下载文件">
<Download size={18} className="text-text-secondary" />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.is_appeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPreview: (task: TaskResponse) => void; toast: ReturnType<typeof useToast> }) {
const riskLevel = getRiskLevel(task, 'video')
const riskConfig = riskLevelConfig[riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.video_file_name || '视频文件'}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || task.project.name}</span>
{task.is_appeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
</div>
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{task.is_appeal && task.appeal_reason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</div>
)}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.video_file_name || '视频文件'}</p>
{task.video_duration && (
<p className="text-xs text-text-tertiary">{formatDuration(task.video_duration)}</p>
)}
</div>
<button type="button" onClick={handlePreview} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="预览视频">
<Eye size={18} className="text-text-secondary" />
</button>
<button type="button" onClick={handleDownload} className="p-2 rounded-lg hover:bg-bg-elevated transition-colors" title="下载文件">
<Download size={18} className="text-text-secondary" />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<Link href={`/agency/review/${task.id}`}>
<Button size="sm" className={`${
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.is_appeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
// ==================== 骨架屏 ====================
function ReviewListSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 w-24 bg-bg-elevated rounded" />
<div className="flex gap-2">
<div className="h-8 w-20 bg-bg-elevated rounded" />
<div className="h-8 w-20 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-10 w-full max-w-md bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[1, 2].map(i => (
<div key={i} className="space-y-3">
<div className="h-8 w-32 bg-bg-elevated rounded" />
{[1, 2, 3].map(j => (
<div key={j} className="h-40 bg-bg-elevated rounded-xl" />
))}
</div>
))}
</div>
</div>
)
}
// ==================== 主页面 ====================
export default function AgencyReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewTask, setPreviewTask] = useState<TaskResponse | null>(null)
const [previewType, setPreviewType] = useState<'script' | 'video'>('script')
const [scriptTasks, setScriptTasks] = useState<TaskResponse[]>([])
const [videoTasks, setVideoTasks] = useState<TaskResponse[]>([])
const [loading, setLoading] = useState(true)
const toast = useToast()
const { subscribe } = useSSE()
const loadData = useCallback(async () => {
if (USE_MOCK) {
setScriptTasks(mockScriptTasks)
setVideoTasks(mockVideoTasks)
setLoading(false)
return
}
try {
const [scriptData, videoData] = await Promise.all([
api.listTasks(1, 50, 'script_agency_review'),
api.listTasks(1, 50, 'video_agency_review'),
])
setScriptTasks(scriptData.items)
setVideoTasks(videoData.items)
} catch (err) {
console.error('Failed to load review tasks:', err)
toast.error('加载审核任务失败')
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
const unsub1 = subscribe('task_updated', () => loadData())
const unsub2 = subscribe('new_task', () => loadData())
return () => { unsub1(); unsub2() }
}, [subscribe, loadData])
if (loading) return <ReviewListSkeleton />
const filteredScripts = scriptTasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creator.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredVideos = videoTasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creator.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const appealScriptCount = scriptTasks.filter(t => t.is_appeal).length
const appealVideoCount = videoTasks.filter(t => t.is_appeal).length
const handleScriptPreview = (task: TaskResponse) => {
setPreviewTask(task)
setPreviewType('script')
}
const handleVideoPreview = (task: TaskResponse) => {
setPreviewTask(task)
setPreviewType('video')
}
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-text-secondary"></span>
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
{scriptTasks.length}
</span>
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{videoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索任务名称或达人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setActiveTab('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('script')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'script' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('video')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'video' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
</div>
{/* 任务列表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{(activeTab === 'all' || activeTab === 'script') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredScripts.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<ScriptTaskCard key={task.id} task={task} onPreview={handleScriptPreview} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<FileText size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
{(activeTab === 'all' || activeTab === 'video') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video size={18} className="text-purple-400" />
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredVideos.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<VideoTaskCard key={task.id} task={task} onPreview={handleVideoPreview} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<Video size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 预览弹窗 */}
<Modal
isOpen={!!previewTask}
onClose={() => setPreviewTask(null)}
title={previewType === 'script' ? (previewTask?.script_file_name || '脚本预览') : (previewTask?.video_file_name || '视频预览')}
size="lg"
>
<div className="space-y-4">
{previewTask?.is_appeal && previewTask?.appeal_reason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewTask.appeal_reason}</p>
</div>
)}
{previewType === 'script' ? (
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
) : (
<div className="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
)}
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewType === 'script' ? previewTask?.script_file_name : previewTask?.video_file_name}</span>
{previewType === 'video' && previewTask?.video_duration && (
<>
<span className="mx-2">·</span>
<span>{formatDuration(previewTask.video_duration)}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewTask(null)}>
</Button>
<Button onClick={() => toast.info(`下载文件: ${previewType === 'script' ? previewTask?.script_file_name : previewTask?.video_file_name}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}