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

578 lines
23 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 { useToast } from '@/components/ui/Toast'
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { Modal, ConfirmModal } from '@/components/ui/Modal'
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import type { TaskResponse, AIReviewResult } from '@/types/task'
// ==================== Mock 数据 ====================
const mockTask: 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,
script_ai_result: {
score: 85,
violations: [
{
type: '违禁词',
content: '效果最好',
severity: 'high',
suggestion: '建议替换为"效果显著"',
timestamp: 15.5,
source: 'speech',
},
{
type: '竞品露出',
content: '疑似竞品Logo',
severity: 'high',
suggestion: '需人工确认是否为竞品露出',
timestamp: 42.0,
source: 'visual',
},
],
soft_warnings: [
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
],
summary: '视频整体合规发现2处硬性问题和1处舆情提示需人工确认',
},
video_ai_score: 85,
video_ai_result: {
score: 85,
violations: [
{
type: '违禁词',
content: '效果最好',
severity: 'high',
suggestion: '建议替换为"效果显著"',
timestamp: 15.5,
source: 'speech',
},
{
type: '竞品露出',
content: '疑似竞品Logo',
severity: 'high',
suggestion: '需人工确认是否为竞品露出',
timestamp: 42.0,
source: 'visual',
},
],
soft_warnings: [
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
],
summary: '视频整体合规发现2处硬性问题和1处舆情提示需人工确认',
},
appeal_count: 0,
is_appeal: false,
created_at: '2026-02-03T10:30:00Z',
updated_at: '2026-02-03T10:35:00Z',
}
// ==================== 工具函数 ====================
function getReviewStepStatus(task: TaskResponse): string {
if (task.stage.includes('agency_review')) return 'agent_reviewing'
if (task.stage.includes('brand_review')) return 'brand_reviewing'
if (task.stage === 'completed') return 'completed'
return 'agent_reviewing'
}
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// ==================== 子组件 ====================
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
const steps = getAgencyReviewSteps(taskStatus)
const currentStep = steps.find(s => s.status === 'current')
return (
<Card className="mb-6">
<CardContent className="py-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-text-primary"></span>
<span className="text-sm text-accent-indigo font-medium">
{currentStep?.label || '代理商审核'}
</span>
</div>
<ReviewSteps steps={steps} />
</CardContent>
</Card>
)
}
function RiskLevelTag({ level }: { level: string }) {
if (level === 'high') return <ErrorTag></ErrorTag>
if (level === 'medium') return <WarningTag></WarningTag>
return <SuccessTag></SuccessTag>
}
function ReviewSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
<div className="space-y-2">
<div className="h-6 w-48 bg-bg-elevated rounded" />
<div className="h-4 w-64 bg-bg-elevated rounded" />
</div>
</div>
<div className="h-16 bg-bg-elevated rounded-xl" />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div className="lg:col-span-3 space-y-4">
<div className="h-64 bg-bg-elevated rounded-xl" />
<div className="h-20 bg-bg-elevated rounded-xl" />
</div>
<div className="lg:col-span-2 space-y-4">
<div className="h-48 bg-bg-elevated rounded-xl" />
<div className="h-32 bg-bg-elevated rounded-xl" />
</div>
</div>
</div>
)
}
// ==================== 主页面 ====================
export default function ReviewPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const taskId = params.id as string
const { subscribe } = useSSE()
const [task, setTask] = useState<TaskResponse | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [showForcePassModal, setShowForcePassModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [forcePassReason, setForcePassReason] = useState('')
const [saveAsException, setSaveAsException] = useState(false)
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const loadTask = useCallback(async () => {
if (USE_MOCK) {
setTask(mockTask)
setLoading(false)
return
}
try {
const data = await api.getTask(taskId)
setTask(data)
} catch (err) {
console.error('Failed to load task:', err)
toast.error('加载任务失败')
} finally {
setLoading(false)
}
}, [taskId, toast])
useEffect(() => {
loadTask()
}, [loadTask])
useEffect(() => {
const unsub1 = subscribe('task_updated', (data: any) => {
if (data?.task_id === taskId) loadTask()
})
const unsub2 = subscribe('review_completed', (data: any) => {
if (data?.task_id === taskId) loadTask()
})
return () => { unsub1(); unsub2() }
}, [subscribe, taskId, loadTask])
if (loading || !task) return <ReviewSkeleton />
// Determine if this is script or video review
const isVideoReview = task.stage.includes('video')
const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result
const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score
const violations = aiResult?.violations || []
const softWarnings = aiResult?.soft_warnings || []
const aiSummary = aiResult?.summary || '暂无 AI 分析总结'
const handleApprove = async () => {
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'pass' })
} else {
await api.reviewScript(taskId, { action: 'pass' })
}
}
toast.success('审核已通过')
setShowApproveModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to approve:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
}
const handleReject = async () => {
if (!rejectReason.trim()) {
toast.error('请填写驳回原因')
return
}
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
} else {
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
}
}
toast.success('已驳回')
setShowRejectModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to reject:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
}
const handleForcePass = async () => {
if (!forcePassReason.trim()) {
toast.error('请填写强制通过原因')
return
}
setSubmitting(true)
try {
if (!USE_MOCK) {
if (isVideoReview) {
await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason })
} else {
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
}
}
toast.success('已强制通过')
setShowForcePassModal(false)
router.push('/agency/review')
} catch (err) {
console.error('Failed to force pass:', err)
toast.error('操作失败,请重试')
} finally {
setSubmitting(false)
}
}
// 时间线标记
const timelineMarkers = [
...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })),
].sort((a, b) => a.time - b.time)
const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10))
return (
<div className="space-y-4">
{/* 顶部导航 */}
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.name}</h1>
<p className="text-sm text-text-secondary">
{task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'}
</p>
</div>
{task.is_appeal && (
<span className="px-3 py-1 bg-accent-amber/20 text-accent-amber rounded-full text-sm font-medium">
</span>
)}
</div>
{/* 申诉理由 */}
{task.is_appeal && task.appeal_reason && (
<Card className="border-accent-amber/30 bg-accent-amber/5">
<CardContent className="py-3">
<p className="text-sm text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
</CardContent>
</Card>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={getReviewStepStatus(task)} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧:视频/脚本播放器 (3/5) */}
<div className="lg:col-span-3 space-y-4">
<Card>
<CardContent className="p-0">
{isVideoReview ? (
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
<button
type="button"
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
</button>
</div>
) : (
<div className="aspect-[4/3] bg-bg-elevated rounded-t-lg flex items-center justify-center">
<div className="text-center">
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1">{task.script_file_name || '脚本文件'}</p>
</div>
</div>
)}
{/* 智能进度条(仅视频且有时间标记时显示) */}
{isVideoReview && timelineMarkers.length > 0 && (
<div className="p-4 border-t border-border-subtle">
<div className="text-sm font-medium text-text-primary mb-3"></div>
<div className="relative h-3 bg-bg-elevated rounded-full">
{timelineMarkers.map((marker, idx) => (
<button
key={idx}
type="button"
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
marker.type === 'hard' ? 'bg-accent-coral' : 'bg-orange-500'
}`}
style={{ left: `${(marker.time / maxTime) * 100}%` }}
title={`${formatTimestamp(marker.time)} - 硬性问题`}
/>
))}
</div>
<div className="flex justify-between text-xs text-text-tertiary mt-1">
<span>0:00</span>
<span>{formatTimestamp(maxTime)}</span>
</div>
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-coral rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-orange-500 rounded-full" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 bg-accent-green rounded-full" />
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* AI 分析总结 */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-text-primary">AI </span>
{aiScore != null && (
<span className={`text-xl font-bold ${aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}>
{aiScore}
</span>
)}
</div>
<p className="text-text-secondary text-sm">{aiSummary}</p>
</CardContent>
</Card>
</div>
{/* 右侧AI 检查单 (2/5) */}
<div className="lg:col-span-2 space-y-4">
{/* 硬性合规 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-red-500" />
({violations.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{violations.length > 0 ? violations.map((v, idx) => {
const key = `v-${idx}`
return (
<div key={key} className={`p-3 rounded-lg border ${checkedViolations[key] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
<div className="flex items-start gap-2">
<input
type="checkbox"
checked={checkedViolations[key] || false}
onChange={() => setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))}
className="mt-1 accent-accent-indigo"
/>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<ErrorTag>{v.type}</ErrorTag>
{v.timestamp != null && (
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
)}
</div>
<p className="text-sm font-medium text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
</div>
</div>
)
}) : (
<div className="text-center py-4 text-text-tertiary text-sm"></div>
)}
</CardContent>
</Card>
{/* 舆情雷达 */}
{softWarnings.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Radio size={16} className="text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{softWarnings.map((w, idx) => (
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
<div className="flex items-center gap-2 mb-1">
<WarningTag>{w.type}</WarningTag>
</div>
<p className="text-sm text-orange-400">{w.content}</p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
{/* 底部决策栏 */}
<Card className="sticky bottom-4 shadow-lg">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="text-sm text-text-secondary">
{Object.values(checkedViolations).filter(Boolean).length}/{violations.length}
</div>
<div className="flex gap-3">
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
</Button>
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
</Button>
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 通过确认弹窗 */}
<ConfirmModal
isOpen={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={handleApprove}
title="确认通过"
message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`}
confirmText="确认通过"
/>
{/* 驳回弹窗 */}
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
<div className="space-y-4">
<p className="text-text-secondary text-sm"></p>
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-sm font-medium text-text-primary mb-2">
({Object.values(checkedViolations).filter(Boolean).length})
</p>
{violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => (
<div key={idx} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
))}
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
<div className="text-sm text-text-tertiary"></div>
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="请详细说明驳回原因..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}></Button>
<Button variant="danger" onClick={handleReject} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
{/* 强制通过弹窗 */}
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
<div className="space-y-4">
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<p className="text-sm text-yellow-400">
<AlertTriangle size={14} className="inline mr-1" />
</p>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1"></label>
<textarea
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
placeholder="例如:达人玩的新梗,品牌方认可"
value={forcePassReason}
onChange={(e) => setForcePassReason(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={saveAsException}
onChange={(e) => setSaveAsException(e.target.checked)}
className="rounded accent-accent-indigo"
/>
<span className="text-sm text-text-secondary"></span>
</label>
<div className="flex gap-3 justify-end">
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}></Button>
<Button onClick={handleForcePass} disabled={submitting}>
{submitting && <Loader2 size={16} className="animate-spin" />}
</Button>
</div>
</div>
</Modal>
</div>
)
}