Your Name 2f9b7f05fd feat(creator): 完成达人端前端页面开发
- 新增申诉中心页面(列表、详情、新建申诉)
- 新增申诉次数管理页面(按任务显示配额,支持向代理商申请)
- 新增个人中心页面(达人ID复制、菜单导航)
- 新增个人信息编辑、账户设置、消息通知设置页面
- 新增帮助中心和历史记录页面
- 新增脚本提交和视频提交页面
- 优化消息中心页面(消息详情跳转)
- 优化任务详情页面布局和交互
- 更新 ResponsiveLayout、Sidebar、ReviewSteps 通用组件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:38:01 +08:00

535 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 } from 'react'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
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 { ReviewSteps, getReviewSteps } from '@/components/ui/ReviewSteps'
import {
ArrowLeft,
Upload,
Video,
CheckCircle,
XCircle,
AlertTriangle,
Clock,
Loader2,
RefreshCw,
Play,
Radio,
Shield
} from 'lucide-react'
// 模拟任务数据
const mockTask = {
id: 'task-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
deadline: '2026-06-18',
videoStatus: 'pending_upload', // pending_upload | ai_reviewing | ai_result | agent_reviewing | agent_rejected | brand_reviewing | brand_passed | brand_rejected
videoFile: null as string | null,
aiResult: null as null | {
score: number
hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }>
sentimentWarnings: Array<{ type: string; content: string; timestamp: number }>
sellingPointsCovered: Array<{ point: string; covered: boolean; timestamp?: number }>
},
agencyReview: null as null | {
result: 'approved' | 'rejected'
comment: string
reviewer: string
time: string
},
brandReview: null as null | {
result: 'approved' | 'rejected'
comment: string
reviewer: string
time: string
},
}
// 根据状态获取模拟数据
function getTaskByStatus(status: string) {
const task = { ...mockTask, videoStatus: status }
if (status === 'ai_result' || status === 'agent_reviewing' || status === 'agent_rejected' || status === 'brand_reviewing' || status === 'brand_passed' || status === 'brand_rejected') {
task.videoFile = '夏日护肤推广.mp4'
task.aiResult = {
score: 85,
hardViolations: [
{ type: '违禁词', content: '效果最好', timestamp: 15.5, suggestion: '建议替换为"效果显著"' },
],
sentimentWarnings: [
{ type: '表情预警', content: '表情过于夸张', timestamp: 42.0 },
],
sellingPointsCovered: [
{ point: 'SPF50+ PA++++', covered: true, timestamp: 25.0 },
{ point: '轻薄质地', covered: true, timestamp: 38.0 },
{ point: '不油腻', covered: true, timestamp: 52.0 },
],
}
}
if (status === 'agent_rejected') {
task.agencyReview = {
result: 'rejected',
comment: '视频中有竞品Logo露出请重新拍摄。',
reviewer: '张经理',
time: '2026-02-06 16:30',
}
}
if (status === 'brand_reviewing' || status === 'brand_passed' || status === 'brand_rejected') {
task.agencyReview = {
result: 'approved',
comment: '视频质量良好,建议通过。',
reviewer: '张经理',
time: '2026-02-06 16:30',
}
}
if (status === 'brand_passed') {
task.brandReview = {
result: 'approved',
comment: '视频通过终审,可以发布。',
reviewer: '品牌方审核员',
time: '2026-02-06 19:00',
}
}
if (status === 'brand_rejected') {
task.brandReview = {
result: 'rejected',
comment: '产品特写时间不足,请补拍。',
reviewer: '品牌方审核员',
time: '2026-02-06 19:00',
}
}
return task
}
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 UploadSection({ onUpload }: { onUpload: () => void }) {
const [file, setFile] = useState<File | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [isUploading, setIsUploading] = useState(false)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
}
}
const handleUpload = () => {
setIsUploading(true)
const timer = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 100) {
clearInterval(timer)
setTimeout(onUpload, 500)
return 100
}
return prev + 10
})
}, 200)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload size={18} className="text-purple-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<Video size={24} className="text-purple-400" />
<span className="text-text-primary">{file.name}</span>
{!isUploading && (
<button
type="button"
onClick={() => setFile(null)}
className="p-1 hover:bg-bg-elevated rounded-full"
>
<XCircle size={16} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {uploadProgress}%</p>
</div>
)}
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> MP4MOVAVI 500MB</p>
<input
type="file"
accept="video/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
</div>
<Button onClick={handleUpload} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交视频'}
</Button>
</CardContent>
</Card>
)
}
function AIReviewingSection() {
const [progress, setProgress] = useState(0)
const [currentStep, setCurrentStep] = useState('正在解析视频...')
useEffect(() => {
const steps = [
'正在解析视频...',
'正在提取音频转文字...',
'正在分析画面内容...',
'正在检测违禁内容...',
'正在分析卖点覆盖...',
'正在生成审核报告...',
]
let stepIndex = 0
const timer = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(timer)
return 100
}
return prev + 5
})
}, 300)
const stepTimer = setInterval(() => {
stepIndex = (stepIndex + 1) % steps.length
setCurrentStep(steps[stepIndex])
}, 1500)
return () => {
clearInterval(timer)
clearInterval(stepTimer)
}
}, [])
return (
<Card>
<CardContent className="py-8 text-center">
<Loader2 size={48} className="mx-auto text-purple-400 mb-4 animate-spin" />
<h3 className="text-lg font-medium text-text-primary mb-2">AI </h3>
<p className="text-text-secondary mb-4"> 3-5 </p>
<div className="w-full max-w-md mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-purple-400 transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary">{progress}%</p>
</div>
<div className="mt-4 p-3 bg-bg-elevated rounded-lg max-w-md mx-auto">
<p className="text-sm text-text-secondary">{currentStep}</p>
</div>
</CardContent>
</Card>
)
}
function AIResultSection({ task }: { task: ReturnType<typeof getTaskByStatus> }) {
if (!task.aiResult) return null
return (
<div className="space-y-4">
{/* AI 评分 */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<span className="text-text-secondary">AI </span>
<span className={`text-3xl font-bold ${task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral'}`}>
{task.aiResult.score}
</span>
</div>
</CardContent>
</Card>
{/* 硬性合规 */}
{task.aiResult.hardViolations.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Shield size={16} className="text-red-500" />
({task.aiResult.hardViolations.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiResult.hardViolations.map((v, idx) => (
<div key={idx} className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div className="flex items-center gap-2 mb-1">
<ErrorTag>{v.type}</ErrorTag>
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
</CardContent>
</Card>
)}
{/* 舆情雷达 */}
{task.aiResult.sentimentWarnings.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">
{task.aiResult.sentimentWarnings.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>
<span className="text-xs text-text-tertiary">{formatTimestamp(w.timestamp)}</span>
</div>
<p className="text-sm text-orange-400">{w.content}</p>
</div>
))}
</CardContent>
</Card>
)}
{/* 卖点覆盖 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle size={16} className="text-accent-green" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{task.aiResult.sellingPointsCovered.map((sp, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-lg bg-bg-elevated">
<div className="flex items-center gap-2">
{sp.covered ? (
<CheckCircle size={16} className="text-accent-green" />
) : (
<XCircle size={16} className="text-accent-coral" />
)}
<span className="text-sm text-text-primary">{sp.point}</span>
</div>
{sp.covered && sp.timestamp && (
<span className="text-xs text-text-tertiary">{formatTimestamp(sp.timestamp)}</span>
)}
</div>
))}
</CardContent>
</Card>
</div>
)
}
function ReviewFeedbackSection({ review, type }: { review: NonNullable<typeof mockTask.agencyReview>; type: 'agency' | 'brand' }) {
const isApproved = review.result === 'approved'
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
return (
<Card className={isApproved ? 'border-accent-green/30' : 'border-accent-coral/30'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isApproved ? (
<CheckCircle size={18} className="text-accent-green" />
) : (
<XCircle size={18} className="text-accent-coral" />
)}
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-primary">{review.reviewer}</span>
{isApproved ? <SuccessTag></SuccessTag> : <ErrorTag></ErrorTag>}
</div>
<p className="text-text-secondary">{review.comment}</p>
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
</CardContent>
</Card>
)
}
function WaitingSection({ message }: { message: string }) {
return (
<Card>
<CardContent className="py-8 text-center">
<Clock size={48} className="mx-auto text-accent-indigo mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">{message}</h3>
<p className="text-text-secondary"></p>
</CardContent>
</Card>
)
}
function SuccessSection() {
return (
<Card className="border-accent-green/30">
<CardContent className="py-8 text-center">
<CheckCircle size={64} className="mx-auto text-accent-green mb-4" />
<h3 className="text-xl font-bold text-text-primary mb-2">🎉 </h3>
<p className="text-text-secondary mb-6"></p>
<div className="flex justify-center gap-3">
<Button variant="secondary">
<Play size={16} />
</Button>
<Button>
</Button>
</div>
</CardContent>
</Card>
)
}
export default function CreatorVideoPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const status = searchParams.get('status') || 'pending_upload'
const [task, setTask] = useState(getTaskByStatus(status))
// 模拟状态切换
const simulateUpload = () => {
setTask(getTaskByStatus('ai_reviewing'))
setTimeout(() => {
setTask(getTaskByStatus('ai_result'))
}, 5000)
}
const handleResubmit = () => {
setTask(getTaskByStatus('pending_upload'))
}
const getStatusDisplay = () => {
switch (task.videoStatus) {
case 'pending_upload': return '待上传视频'
case 'ai_reviewing': return 'AI 审核中'
case 'ai_result': return 'AI 审核完成'
case 'agent_reviewing': return '代理商审核中'
case 'agent_rejected': return '代理商驳回'
case 'brand_reviewing': return '品牌方终审中'
case 'brand_passed': return '审核通过'
case 'brand_rejected': return '品牌方驳回'
default: return '未知状态'
}
}
return (
<div className="space-y-6 max-w-2xl mx-auto">
{/* 顶部导航 */}
<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.projectName}</h1>
<p className="text-sm text-text-secondary"> · {getStatusDisplay()}</p>
</div>
</div>
{/* 审核流程进度条 */}
<Card>
<CardContent className="py-4">
<ReviewSteps steps={getReviewSteps(task.videoStatus)} />
</CardContent>
</Card>
{/* 根据状态显示不同内容 */}
{task.videoStatus === 'pending_upload' && (
<UploadSection onUpload={simulateUpload} />
)}
{task.videoStatus === 'ai_reviewing' && (
<AIReviewingSection />
)}
{task.videoStatus === 'ai_result' && (
<>
<AIResultSection task={task} />
<WaitingSection message="等待代理商审核" />
</>
)}
{task.videoStatus === 'agent_reviewing' && (
<>
<AIResultSection task={task} />
<WaitingSection message="等待代理商审核" />
</>
)}
{task.videoStatus === 'agent_rejected' && task.agencyReview && (
<>
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<div className="flex gap-3">
<Button variant="secondary" onClick={handleResubmit} fullWidth>
<RefreshCw size={16} />
</Button>
</div>
</>
)}
{task.videoStatus === 'brand_reviewing' && task.agencyReview && (
<>
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<WaitingSection message="等待品牌方终审" />
</>
)}
{task.videoStatus === 'brand_passed' && task.agencyReview && task.brandReview && (
<>
<SuccessSection />
<ReviewFeedbackSection review={task.brandReview} type="brand" />
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
</>
)}
{task.videoStatus === 'brand_rejected' && task.agencyReview && task.brandReview && (
<>
<ReviewFeedbackSection review={task.brandReview} type="brand" />
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<div className="flex gap-3">
<Button variant="secondary" onClick={handleResubmit} fullWidth>
<RefreshCw size={16} />
</Button>
</div>
</>
)}
</div>
)
}