- 新增基础设施: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>
423 lines
18 KiB
TypeScript
423 lines
18 KiB
TypeScript
'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>
|
||
)
|
||
}
|