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

423 lines
18 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
AlertTriangle,
Clock,
CheckCircle,
ChevronRight,
FileVideo,
MessageSquare,
TrendingUp,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { AgencyDashboard as AgencyDashboardType } from '@/types/dashboard'
import type { TaskResponse } from '@/types/task'
import type { ProjectResponse } from '@/types/project'
// ==================== Mock 数据 ====================
const mockStats: AgencyDashboardType = {
pending_review: { script: 8, video: 4 },
pending_appeal: 3,
today_passed: { script: 18, video: 10 },
in_progress: { script: 25, video: 20 },
total_creators: 15,
total_tasks: 80,
}
const mockPendingTasks: TaskResponse[] = [
{
id: 'task-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_ai_score: 85, appeal_count: 0, is_appeal: false,
created_at: '2026-02-04T14:30:00Z', updated_at: '2026-02-04T14:30:00Z',
},
{
id: 'task-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_ai_score: 72, appeal_count: 0, is_appeal: true,
created_at: '2026-02-04T13:45:00Z', updated_at: '2026-02-04T13:45:00Z',
},
{
id: 'task-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_ai_score: 68, appeal_count: 0, is_appeal: false,
created_at: '2026-02-04T14:50:00Z', updated_at: '2026-02-04T14:50:00Z',
},
]
const mockProjects: ProjectResponse[] = [
{
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-06-18', agencies: [], task_count: 20,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-03-15', agencies: [], task_count: 12,
created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-002', brand_name: 'YY品牌',
status: 'active', deadline: '2026-09-01', agencies: [], task_count: 15,
created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
},
]
// ==================== 组件 ====================
function UrgentLevelIcon({ level }: { level: string }) {
if (level === 'high') return <AlertTriangle size={16} className="text-red-500" />
if (level === 'medium') return <MessageSquare size={16} className="text-orange-500" />
return <CheckCircle size={16} className="text-yellow-500" />
}
function getTaskUrgencyLevel(task: TaskResponse): string {
const aiScore = task.stage.startsWith('script') ? task.script_ai_score : task.video_ai_score
if (aiScore != null && aiScore < 60) return 'high'
if (task.is_appeal) return 'medium'
return 'low'
}
function getTaskUrgencyTitle(task: TaskResponse): string {
const type = task.stage.includes('video') ? '视频' : '脚本'
return `${task.creator.name}${type} - ${task.name}`
}
function getTaskTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const hours = Math.floor(diff / 3600000)
if (hours < 1) return `${Math.floor(diff / 60000)}分钟前`
if (hours < 24) return `${hours}小时前`
return `${Math.floor(hours / 24)}天前`
}
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 w-40 bg-bg-elevated rounded" />
<div className="h-5 w-48 bg-bg-elevated rounded" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-28 bg-bg-elevated rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="h-64 bg-bg-elevated rounded-xl" />
<div className="lg:col-span-2 h-64 bg-bg-elevated rounded-xl" />
</div>
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
)
}
export default function AgencyDashboard() {
const [stats, setStats] = useState<AgencyDashboardType | null>(null)
const [pendingTasks, setPendingTasks] = useState<TaskResponse[]>([])
const [projects, setProjects] = useState<ProjectResponse[]>([])
const [loading, setLoading] = useState(true)
const { subscribe } = useSSE()
const loadData = useCallback(async () => {
if (USE_MOCK) {
setStats(mockStats)
setPendingTasks(mockPendingTasks)
setProjects(mockProjects)
setLoading(false)
return
}
try {
const [dashboardData, tasksData, projectsData] = await Promise.all([
api.getAgencyDashboard(),
api.listPendingReviews(1, 5),
api.listProjects(1, 3),
])
setStats(dashboardData)
// listPendingReviews returns TaskSummary, but we need TaskResponse for the table
// Fall back to listTasks for the pending table
const fullTasks = await api.listTasks(1, 5, 'script_agency_review')
setPendingTasks(fullTasks.items)
setProjects(projectsData.items)
} catch (err) {
console.error('Failed to load agency dashboard:', err)
// fallback to empty
setStats({ pending_review: { script: 0, video: 0 }, pending_appeal: 0, today_passed: { script: 0, video: 0 }, in_progress: { script: 0, video: 0 }, total_creators: 0, total_tasks: 0 })
setPendingTasks([])
setProjects([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
const unsub1 = subscribe('task_updated', () => loadData())
const unsub2 = subscribe('new_task', () => loadData())
const unsub3 = subscribe('review_completed', () => loadData())
return () => { unsub1(); unsub2(); unsub3() }
}, [subscribe, loadData])
if (loading || !stats) return <DashboardSkeleton />
// Build urgent todos from pending tasks (top 3)
const urgentTodos = pendingTasks.slice(0, 3).map(task => ({
id: task.id,
title: getTaskUrgencyTitle(task),
description: task.project.name,
time: getTaskTimeAgo(task.updated_at),
level: getTaskUrgencyLevel(task),
}))
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-text-primary"></h1>
<div className="text-sm text-text-secondary">{new Date().toLocaleString('zh-CN')}</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-accent-coral/20 to-bg-card border-accent-coral/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-coral">{stats.pending_review.script + stats.pending_review.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.pending_review.script}</span>
<span> {stats.pending_review.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-coral/20 flex items-center justify-center">
<Clock size={24} className="text-accent-coral" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-500/20 to-bg-card border-orange-500/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-orange-400">{stats.pending_appeal}</div>
</div>
<div className="w-12 h-12 rounded-full bg-orange-500/20 flex items-center justify-center">
<MessageSquare size={24} className="text-orange-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent-green/20 to-bg-card border-accent-green/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-green">{stats.today_passed.script + stats.today_passed.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.today_passed.script}</span>
<span> {stats.today_passed.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-green/20 flex items-center justify-center">
<CheckCircle size={24} className="text-accent-green" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent-indigo/20 to-bg-card border-accent-indigo/30">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-text-secondary"></div>
<div className="text-3xl font-bold text-accent-indigo">{stats.in_progress.script + stats.in_progress.video}</div>
<div className="flex gap-3 mt-1 text-xs text-text-tertiary">
<span> {stats.in_progress.script}</span>
<span> {stats.in_progress.video}</span>
</div>
</div>
<div className="w-12 h-12 rounded-full bg-accent-indigo/20 flex items-center justify-center">
<FileVideo size={24} className="text-accent-indigo" />
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 紧急待办 */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-red-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{urgentTodos.length > 0 ? urgentTodos.map((todo) => (
<Link
key={todo.id}
href={`/agency/review/${todo.id}`}
className="block p-3 rounded-lg border border-border-subtle hover:border-accent-indigo hover:bg-accent-indigo/5 transition-colors"
>
<div className="flex items-start gap-3">
<UrgentLevelIcon level={todo.level} />
<div className="flex-1 min-w-0">
<div className="font-medium text-text-primary truncate">{todo.title}</div>
<div className="text-sm text-text-secondary">{todo.description}</div>
<div className="text-xs text-text-tertiary mt-1">{todo.time}</div>
</div>
<ChevronRight size={16} className="text-text-tertiary flex-shrink-0" />
</div>
</Link>
)) : (
<div className="text-center py-6 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
{/* 项目概览 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp size={18} className="text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{projects.length > 0 ? projects.map((project) => (
<div key={project.id} className="p-4 rounded-lg bg-bg-elevated">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{project.name}</span>
{project.brand_name && (
<span className="text-xs text-text-tertiary">({project.brand_name})</span>
)}
</div>
<span className="text-sm text-text-secondary">
{project.task_count}
</span>
</div>
<div className="flex items-center justify-between text-xs text-text-tertiary">
<span>: {project.status === 'active' ? '进行中' : project.status === 'completed' ? '已完成' : '已归档'}</span>
{project.deadline && <span>: {new Date(project.deadline).toLocaleDateString('zh-CN')}</span>}
</div>
</div>
)) : (
<div className="text-center py-6 text-text-tertiary text-sm"></div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 待审核列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<Link href="/agency/review">
<Button variant="ghost" size="sm">
<ChevronRight size={16} />
</Button>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium">AI评分</th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{pendingTasks.length > 0 ? pendingTasks.map((task) => {
const isVideo = task.stage.includes('video')
const aiScore = isVideo ? task.video_ai_score : task.script_ai_score
return (
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
<td className="py-4">
<div className="flex items-center gap-2">
<div className="font-medium text-text-primary">{task.name}</div>
{task.is_appeal && (
<span className="px-1.5 py-0.5 text-xs bg-accent-amber/20 text-accent-amber rounded">
</span>
)}
</div>
</td>
<td className="py-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
isVideo ? 'bg-purple-500/20 text-purple-400' : 'bg-accent-indigo/20 text-accent-indigo'
}`}>
{isVideo ? '视频' : '脚本'}
</span>
</td>
<td className="py-4 text-text-secondary">{task.creator.name}</td>
<td className="py-4 text-text-secondary">{task.project.brand_name || task.project.name}</td>
<td className="py-4">
{aiScore != null ? (
<span className={`font-medium ${
aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'
}`}>
{aiScore}
</span>
) : (
<span className="text-text-tertiary">-</span>
)}
</td>
<td className="py-4 text-sm text-text-tertiary">
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="py-4">
<Link href={`/agency/review/${task.id}`}>
<Button size="sm"></Button>
</Link>
</td>
</tr>
)
}) : (
<tr>
<td colSpan={7} className="py-8 text-center text-text-tertiary"></td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}