Your Name a8be7bbca9 feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用:
- 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API
- 添加 loading 骨架屏、error toast 提示、submitting 状态管理
- 数据映射:TaskResponse → 页面视图模型,处理类型差异
- 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo
- Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等
- 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:29:43 +08:00

454 lines
16 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 { useRouter, useParams } from 'next/navigation'
import { ArrowLeft, Download, Play, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { TaskResponse, TaskStage } from '@/types/task'
// ==================== 本地视图模型 ====================
interface TaskViewModel {
id: string
videoTitle: string
creatorName: string
brandName: string
platform: string
status: string
aiScore: number | null
finalScore: number | null
aiSummary: string
submittedAt: string
reviewedAt: string
reviewerName: string
reviewNotes: string
videoUrl: string | null
softWarnings: Array<{ id: string; content: string; suggestion: string }>
timeline: Array<{ time: string; event: string; actor: string }>
}
// ==================== Mock 数据 ====================
const mockTaskDetail: TaskViewModel = {
id: 'task-004',
videoTitle: '美食探店vlog',
creatorName: '吃货小胖',
brandName: '某餐饮品牌',
platform: '小红书',
status: 'approved',
aiScore: 95,
finalScore: 95,
aiSummary: '视频内容合规,无明显违规项',
submittedAt: '2024-02-04 10:00',
reviewedAt: '2024-02-04 12:00',
reviewerName: '审核员A',
reviewNotes: '内容积极正面,品牌露出合适,通过审核。',
videoUrl: null,
softWarnings: [
{ id: 'w1', content: '品牌提及次数适中', suggestion: '可考虑适当增加品牌提及' },
],
timeline: [
{ time: '2024-02-04 10:00', event: '达人提交视频', actor: '吃货小胖' },
{ time: '2024-02-04 10:02', event: 'AI审核开始', actor: '系统' },
{ time: '2024-02-04 10:05', event: 'AI审核完成得分95分', actor: '系统' },
{ time: '2024-02-04 12:00', event: '人工审核通过', actor: '审核员A' },
],
}
// ==================== 辅助函数 ====================
function mapStageToStatus(stage: TaskStage, task: TaskResponse): string {
if (stage === 'completed') return 'approved'
if (stage === 'rejected') return 'rejected'
// 检查视频审核状态
if (task.video_agency_status === 'passed' || task.video_brand_status === 'passed') return 'approved'
if (task.video_agency_status === 'rejected' || task.video_brand_status === 'rejected') return 'rejected'
// 检查脚本审核状态
if (task.script_agency_status === 'passed' || task.script_brand_status === 'passed') {
// 脚本通过但视频还在流程中
if (stage.startsWith('video_')) return 'pending_review'
return 'approved'
}
if (task.script_agency_status === 'rejected' || task.script_brand_status === 'rejected') return 'rejected'
return 'pending_review'
}
function formatDateTime(isoStr: string | null | undefined): string {
if (!isoStr) return '-'
try {
const d = new Date(isoStr)
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
} catch {
return isoStr
}
}
function buildTimeline(task: TaskResponse): Array<{ time: string; event: string; actor: string }> {
const timeline: Array<{ time: string; event: string; actor: string }> = []
// 任务创建
timeline.push({
time: formatDateTime(task.created_at),
event: '任务创建',
actor: '系统',
})
// 脚本上传
if (task.script_uploaded_at) {
timeline.push({
time: formatDateTime(task.script_uploaded_at),
event: '达人提交脚本',
actor: task.creator?.name || '达人',
})
}
// 脚本 AI 审核
if (task.script_ai_score != null) {
timeline.push({
time: formatDateTime(task.script_uploaded_at),
event: `AI 脚本审核完成,得分 ${task.script_ai_score}`,
actor: '系统',
})
}
// 脚本代理商审核
if (task.script_agency_status && task.script_agency_status !== 'pending') {
const statusText = task.script_agency_status === 'passed' ? '通过' :
task.script_agency_status === 'rejected' ? '驳回' : '强制通过'
timeline.push({
time: formatDateTime(task.updated_at),
event: `代理商脚本审核${statusText}`,
actor: task.agency?.name || '代理商',
})
}
// 脚本品牌方审核
if (task.script_brand_status && task.script_brand_status !== 'pending') {
const statusText = task.script_brand_status === 'passed' ? '通过' :
task.script_brand_status === 'rejected' ? '驳回' : '强制通过'
timeline.push({
time: formatDateTime(task.updated_at),
event: `品牌方脚本审核${statusText}`,
actor: '品牌方',
})
}
// 视频上传
if (task.video_uploaded_at) {
timeline.push({
time: formatDateTime(task.video_uploaded_at),
event: '达人提交视频',
actor: task.creator?.name || '达人',
})
}
// 视频 AI 审核
if (task.video_ai_score != null) {
timeline.push({
time: formatDateTime(task.video_uploaded_at),
event: `AI 视频审核完成,得分 ${task.video_ai_score}`,
actor: '系统',
})
}
// 视频代理商审核
if (task.video_agency_status && task.video_agency_status !== 'pending') {
const statusText = task.video_agency_status === 'passed' ? '通过' :
task.video_agency_status === 'rejected' ? '驳回' : '强制通过'
timeline.push({
time: formatDateTime(task.updated_at),
event: `代理商视频审核${statusText}`,
actor: task.agency?.name || '代理商',
})
}
// 视频品牌方审核
if (task.video_brand_status && task.video_brand_status !== 'pending') {
const statusText = task.video_brand_status === 'passed' ? '通过' :
task.video_brand_status === 'rejected' ? '驳回' : '强制通过'
timeline.push({
time: formatDateTime(task.updated_at),
event: `品牌方视频审核${statusText}`,
actor: '品牌方',
})
}
// 申诉
if (task.is_appeal && task.appeal_reason) {
timeline.push({
time: formatDateTime(task.updated_at),
event: `达人发起申诉:${task.appeal_reason}`,
actor: task.creator?.name || '达人',
})
}
return timeline
}
function mapTaskResponseToViewModel(task: TaskResponse): TaskViewModel {
const status = mapStageToStatus(task.stage, task)
// 选择最新的 AI 评分(优先视频,其次脚本)
const aiScore = task.video_ai_score ?? task.script_ai_score ?? null
const aiResult = task.video_ai_result ?? task.script_ai_result ?? null
// 最终评分等于 AI 评分(人工审核不改分)
const finalScore = aiScore
// AI 摘要
const aiSummary = aiResult?.summary || '暂无 AI 分析摘要'
// 审核备注(优先视频代理商审核意见)
const reviewNotes = task.video_agency_comment || task.script_agency_comment ||
task.video_brand_comment || task.script_brand_comment || ''
// 软警告
const softWarnings = (aiResult?.soft_warnings || []).map((w, i) => ({
id: `w-${i}`,
content: w.content,
suggestion: w.suggestion,
}))
// 时间线
const timeline = buildTimeline(task)
return {
id: task.id,
videoTitle: task.name,
creatorName: task.creator?.name || '未知达人',
brandName: task.project?.brand_name || '未知品牌',
platform: '小红书', // 后端暂无 platform 字段
status,
aiScore,
finalScore,
aiSummary,
submittedAt: formatDateTime(task.video_uploaded_at || task.script_uploaded_at || task.created_at),
reviewedAt: formatDateTime(task.updated_at),
reviewerName: task.agency?.name || '-',
reviewNotes,
videoUrl: task.video_file_url || null,
softWarnings,
timeline,
}
}
// ==================== 组件 ====================
function StatusBadge({ status }: { status: string }) {
if (status === 'approved') return <SuccessTag></SuccessTag>
if (status === 'rejected') return <ErrorTag></ErrorTag>
if (status === 'pending_review') return <WarningTag></WarningTag>
return <PendingTag></PendingTag>
}
function TaskDetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="flex-1">
<div className="h-6 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-64 bg-bg-elevated rounded mt-2" />
</div>
<div className="h-10 w-28 bg-bg-elevated rounded-lg" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="aspect-video bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
<div className="space-y-4">
<div className="h-64 bg-bg-elevated rounded-xl" />
<div className="h-48 bg-bg-elevated rounded-xl" />
</div>
</div>
</div>
)
}
export default function TaskDetailPage() {
const router = useRouter()
const params = useParams()
const taskId = params.id as string
const [task, setTask] = useState<TaskViewModel>(mockTaskDetail)
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
if (USE_MOCK) {
setTask(mockTaskDetail)
setLoading(false)
return
}
try {
const taskData = await api.getTask(taskId)
setTask(mapTaskResponseToViewModel(taskData))
} catch (err) {
console.error('加载任务详情失败:', err)
// 加载失败时保持 mock 数据作为 fallback
} finally {
setLoading(false)
}
}, [taskId])
useEffect(() => {
loadData()
}, [loadData])
if (loading) {
return <TaskDetailSkeleton />
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full">
<ArrowLeft size={20} />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-gray-900">{task.videoTitle}</h1>
<StatusBadge status={task.status} />
</div>
<p className="text-sm text-gray-500">{task.creatorName} · {task.brandName} · {task.platform}</p>
</div>
<Button variant="secondary" icon={Download}></Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:视频和基本信息 */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardContent className="p-0">
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center">
{task.videoUrl ? (
<video
src={task.videoUrl}
controls
className="w-full h-full rounded-t-lg object-contain"
/>
) : (
<button type="button" className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30">
<Play size={32} className="text-white ml-1" />
</button>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-sm text-gray-500">AI </div>
<div className={`text-3xl font-bold ${task.aiScore != null && task.aiScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
{task.aiScore ?? '-'}
</div>
</div>
<div>
<div className="text-sm text-gray-500"></div>
<div className={`text-3xl font-bold ${task.finalScore != null && task.finalScore >= 80 ? 'text-green-600' : 'text-yellow-600'}`}>
{task.finalScore ?? '-'}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t">
<div className="text-sm text-gray-500 mb-1">AI </div>
<p className="text-gray-700">{task.aiSummary}</p>
</div>
{task.reviewNotes && (
<div className="mt-4 pt-4 border-t">
<div className="text-sm text-gray-500 mb-1"></div>
<p className="text-gray-700">{task.reviewNotes}</p>
</div>
)}
</CardContent>
</Card>
{task.softWarnings.length > 0 && (
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-3">
{task.softWarnings.map((w) => (
<div key={w.id} className="p-3 bg-yellow-50 rounded-lg">
<p className="font-medium text-yellow-800">{w.content}</p>
<p className="text-sm text-yellow-600 mt-1">{w.suggestion}</p>
</div>
))}
</CardContent>
</Card>
)}
</div>
{/* 右侧:详细信息和时间线 */}
<div className="space-y-4">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-500">ID</span>
<span className="text-gray-900 font-mono text-sm">{task.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900">{task.creatorName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900">{task.brandName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900">{task.platform}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900 text-sm">{task.submittedAt}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900 text-sm">{task.reviewedAt}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-gray-900">{task.reviewerName}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>线</CardTitle></CardHeader>
<CardContent>
<div className="space-y-4">
{task.timeline.map((item, index) => (
<div key={index} className="flex gap-3">
<div className="flex flex-col items-center">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
{index < task.timeline.length - 1 && <div className="w-0.5 h-full bg-gray-200 mt-1" />}
</div>
<div className="flex-1 pb-4">
<p className="text-sm font-medium text-gray-900">{item.event}</p>
<p className="text-xs text-gray-500 mt-1">{item.time} · {item.actor}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}