Your Name a5a005db0c feat: 完善审核台文件预览与消息通知系统
主要更新:
- 新增 FilePreview 通用组件,支持视频/图片/PDF 内嵌预览
- 审核详情页添加文件信息卡片、预览/下载功能
- 审核列表和详情页添加申诉标识和申诉理由显示
- 完善三端消息通知系统(达人/代理商/品牌)
- 新增达人 Brief 查看页面
- 新增品牌方消息中心页面
- 创建后端开发备忘文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 12:20:47 +08:00

665 lines
22 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 } from 'react'
import { useRouter } from 'next/navigation'
import {
UserPlus,
ClipboardList,
CheckCircle,
PenLine,
ScanSearch,
Building2,
XCircle,
BadgeCheck,
Video,
MessageCircle,
CalendarClock,
FileText,
Bell,
Eye,
Clock
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
// 消息类型
type MessageType =
| 'invite' // 代理商邀请
| 'new_task' // 新任务分配
| 'pass' // 审核通过
| 'need_fix' // 需要修改
| 'ai_complete' // AI审核完成
| 'agency_pass' // 代理商审核通过
| 'agency_reject' // 代理商审核驳回
| 'brand_pass' // 品牌方审核通过
| 'brand_reject' // 品牌方审核驳回
| 'video_ai' // 视频AI审核完成
| 'appeal' // 申诉结果(通用)
| 'appeal_success' // 申诉成功(违规被撤销)
| 'appeal_failed' // 申诉失败(维持原判)
| 'appeal_quota_approved' // 申请增加申诉次数成功
| 'appeal_quota_rejected' // 申请增加申诉次数失败
| 'video_agency_reject' // 视频代理商驳回
| 'video_brand_reject' // 视频品牌方驳回
| 'task_deadline' // 任务截止提醒
| 'brief_updated' // Brief更新通知
| 'system_notice' // 系统通知
type Message = {
id: string
type: MessageType
title: string
content: string
time: string
read: boolean
taskId?: string
hasActions?: boolean // 是否有操作按钮(邀请类型)
agencyName?: string // 代理商名称(新任务类型)
taskName?: string // 任务名称(新任务类型)
}
// 消息配置
const messageConfig: Record<MessageType, {
icon: React.ElementType
iconColor: string
bgColor: string
}> = {
invite: { icon: UserPlus, iconColor: 'text-purple-400', bgColor: 'bg-purple-500/20' },
new_task: { icon: ClipboardList, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
need_fix: { icon: PenLine, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
ai_complete: { icon: ScanSearch, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
agency_pass: { icon: Building2, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
agency_reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
brand_pass: { icon: BadgeCheck, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
brand_reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
video_ai: { icon: Video, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
appeal: { icon: MessageCircle, iconColor: 'text-accent-blue', bgColor: 'bg-accent-blue/20' },
appeal_success: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
appeal_failed: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
appeal_quota_approved: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
appeal_quota_rejected: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
video_agency_reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
video_brand_reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
brief_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
}
// 12条消息数据
const mockMessages: Message[] = [
{
id: 'msg-001',
type: 'invite',
title: '代理商邀请',
content: '「星辰传媒」邀请您成为签约达人,加入后可接收该代理商分配的推广任务',
time: '5分钟前',
read: false,
hasActions: true,
},
{
id: 'msg-002',
type: 'new_task',
title: '新任务分配',
content: '您被「星辰传媒」安排了新任务【XX品牌618推广】请先查看任务要求后再提交脚本',
time: '10分钟前',
read: false,
taskId: 'task-001',
agencyName: '星辰传媒',
taskName: 'XX品牌618推广',
},
{
id: 'msg-003',
type: 'pass',
title: '审核通过',
content: '恭喜您的视频【AA数码新品发布】已通过审核可安排发布',
time: '2小时前',
read: true,
taskId: 'task-004',
},
{
id: 'msg-004',
type: 'need_fix',
title: '需要修改',
content: '您的视频【ZZ饮品夏日】有2处需修改00:15竞品露出、00:42口播违规词点击查看详情',
time: '昨天 14:30',
read: true,
taskId: 'task-003',
},
{
id: 'msg-005',
type: 'ai_complete',
title: 'AI审核完成',
content: '您的脚本【CC服装春季款】AI预审已完成已进入代理商审核',
time: '30分钟前',
read: true,
taskId: 'task-006',
},
{
id: 'msg-006',
type: 'agency_pass',
title: '代理商审核通过',
content: '您的脚本【DD家电测评】已通过代理商审核等待品牌方终审',
time: '1小时前',
read: true,
taskId: 'task-007',
},
{
id: 'msg-007',
type: 'agency_reject',
title: '代理商审核驳回',
content: '您的脚本【HH美妆代言】被代理商驳回原因品牌调性不符请修改后重新提交',
time: '2小时前',
read: true,
taskId: 'task-010',
},
{
id: 'msg-008',
type: 'brand_pass',
title: '品牌方审核通过',
content: '恭喜您的脚本【EE食品试吃】已通过品牌方终审请在7天内上传视频',
time: '昨天 14:30',
read: true,
taskId: 'task-008',
},
{
id: 'msg-009',
type: 'brand_reject',
title: '品牌方审核驳回',
content: '您的脚本【II数码配件】被品牌方驳回原因产品卖点不够突出请修改后重新提交',
time: '昨天 16:45',
read: true,
taskId: 'task-011',
},
{
id: 'msg-010',
type: 'video_ai',
title: '视频AI审核完成',
content: '您的视频【JJ旅行vlog】AI预审已完成已进入代理商审核环节',
time: '今天 09:15',
read: true,
taskId: 'task-013',
},
{
id: 'msg-011',
type: 'appeal_success',
title: '申诉成功',
content: '您对【ZZ饮品夏日】的申诉已通过违规已被撤销申诉次数已返还',
time: '2天前',
read: false,
taskId: 'task-003',
},
{
id: 'msg-011b',
type: 'appeal_failed',
title: '申诉未通过',
content: '您对【HH美妆代言】的申诉未通过维持原审核结果请根据建议修改后重新提交',
time: '2天前',
read: true,
taskId: 'task-011',
},
{
id: 'msg-011c',
type: 'appeal_quota_approved',
title: '申诉次数申请通过',
content: '您申请增加【AA数码新品发布】的申诉次数已被「星辰传媒」批准当前可用申诉次数 +1',
time: '3天前',
read: false,
taskId: 'task-004',
},
{
id: 'msg-011d',
type: 'appeal_quota_rejected',
title: '申诉次数申请被拒',
content: '您申请增加【BB运动饮料】的申诉次数被「星辰传媒」拒绝请仔细阅读驳回原因后修改内容',
time: '3天前',
read: true,
taskId: 'task-005',
},
{
id: 'msg-012',
type: 'video_agency_reject',
title: '视频代理商审核驳回',
content: '您的视频【KK宠物用品】被代理商驳回原因背景音乐版权问题请修改后重新提交',
time: '今天 11:20',
read: true,
taskId: 'task-014',
},
{
id: 'msg-013',
type: 'video_brand_reject',
title: '视频品牌方审核驳回',
content: '您的视频【MM厨房电器】被品牌方驳回原因产品使用场景不够真实请修改后重新提交',
time: '昨天 18:30',
read: true,
taskId: 'task-015',
},
{
id: 'msg-014',
type: 'task_deadline',
title: '任务即将截止',
content: '您的任务【XX品牌618推广】将于3天后截止请尽快提交脚本',
time: '今天 08:00',
read: false,
taskId: 'task-001',
},
{
id: 'msg-015',
type: 'brief_updated',
title: 'Brief更新通知',
content: '【XX品牌618推广】的Brief要求已更新新增2个必选卖点请查看最新要求',
time: '昨天 15:00',
read: true,
taskId: 'task-001',
},
{
id: 'msg-017',
type: 'system_notice',
title: '系统通知',
content: '平台违禁词库已更新,请在创作时注意避免使用新增的违禁词',
time: '4天前',
read: true,
},
]
// 消息卡片组件
function MessageCard({
message,
onRead,
onNavigate,
onViewBrief,
onAcceptInvite,
onIgnoreInvite,
}: {
message: Message
onRead: () => void
onNavigate: () => void
onViewBrief?: () => void
onAcceptInvite?: () => void
onIgnoreInvite?: () => void
}) {
const config = messageConfig[message.type]
const Icon = config.icon
return (
<div
className={cn(
'rounded-xl p-4 flex gap-4 transition-colors',
message.read
? 'bg-transparent border border-bg-elevated'
: 'bg-bg-elevated',
// 新任务和邀请类型不整体点击跳转
message.type !== 'new_task' && message.type !== 'invite' && message.taskId ? 'cursor-pointer' : ''
)}
onClick={() => {
if (message.type !== 'new_task' && message.type !== 'invite') {
onRead()
if (message.taskId) onNavigate()
}
}}
>
{/* 图标 */}
<div className={cn('w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0', config.bgColor)}>
<Icon size={24} className={config.iconColor} />
</div>
{/* 内容 */}
<div className="flex-1 flex flex-col gap-2">
{/* 头部 */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-semibold text-text-primary">{message.title}</span>
<span className="text-xs text-text-secondary">{message.time}</span>
</div>
{/* 描述 */}
<p className="text-sm text-text-secondary leading-relaxed">{message.content}</p>
{/* 新任务类型的操作按钮 */}
{message.type === 'new_task' && message.taskId && (
<div className="flex items-center gap-3 pt-2">
<button
type="button"
className="px-4 py-2 rounded-md bg-accent-indigo text-white text-sm font-medium hover:bg-accent-indigo/90 transition-colors"
onClick={(e) => {
e.stopPropagation()
onRead()
onViewBrief?.()
}}
>
</button>
<button
type="button"
className="px-4 py-2 rounded-md border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-elevated transition-colors"
onClick={(e) => {
e.stopPropagation()
onRead()
onNavigate()
}}
>
</button>
</div>
)}
{/* 邀请类型的操作按钮 */}
{message.hasActions && (
<div className="flex items-center gap-3 pt-2">
<button
type="button"
className="px-4 py-2 rounded-md bg-accent-indigo text-white text-sm font-medium hover:bg-accent-indigo/90 transition-colors"
onClick={(e) => {
e.stopPropagation()
onAcceptInvite?.()
}}
>
</button>
<button
type="button"
className="px-4 py-2 rounded-md border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-elevated transition-colors"
onClick={(e) => {
e.stopPropagation()
onIgnoreInvite?.()
}}
>
</button>
</div>
)}
</div>
{/* 未读标记 */}
{!message.read && (
<div className="w-2.5 h-2.5 rounded-full bg-accent-indigo flex-shrink-0 mt-1" />
)}
</div>
)
}
// 邀请确认弹窗
function InviteConfirmModal({
isOpen,
type,
agencyName,
onClose,
onConfirm,
}: {
isOpen: boolean
type: 'accept' | 'ignore'
agencyName: string
onClose: () => void
onConfirm: () => void
}) {
if (!isOpen) return null
const isAccept = type === 'accept'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-card rounded-2xl p-6 w-full max-w-md mx-4 card-shadow">
<h3 className="text-xl font-bold text-text-primary mb-2">
{isAccept ? '确认接受邀请' : '确认忽略邀请'}
</h3>
<p className="text-sm text-text-secondary mb-6">
{isAccept
? `您确定要接受「${agencyName}」的签约邀请吗?接受后您将成为该代理商的签约达人,可以接收推广任务。`
: `您确定要忽略「${agencyName}」的邀请吗?您可以稍后在消息中心重新查看此邀请。`}
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 rounded-xl bg-bg-elevated text-text-primary text-sm font-medium"
>
</button>
<button
type="button"
onClick={onConfirm}
className={cn(
'flex-1 py-3 rounded-xl text-sm font-semibold',
isAccept ? 'bg-accent-indigo text-white' : 'bg-accent-coral text-white'
)}
>
{isAccept ? '确认接受' : '确认忽略'}
</button>
</div>
</div>
</div>
)
}
// 成功提示弹窗
function SuccessModal({
isOpen,
message,
onClose,
}: {
isOpen: boolean
message: string
onClose: () => void
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-bg-card rounded-2xl p-8 w-full max-w-sm mx-4 card-shadow flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-accent-green/15 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-accent-green" />
</div>
<p className="text-base font-semibold text-text-primary text-center">{message}</p>
<button
type="button"
onClick={onClose}
className="px-8 py-2.5 rounded-xl bg-accent-indigo text-white text-sm font-medium"
>
</button>
</div>
</div>
)
}
export default function CreatorMessagesPage() {
const router = useRouter()
const [messages, setMessages] = useState(mockMessages)
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
isOpen: false,
type: 'accept',
messageId: '',
})
const [successModal, setSuccessModal] = useState<{ isOpen: boolean; message: string }>({
isOpen: false,
message: '',
})
const markAsRead = (id: string) => {
setMessages(prev => prev.map(msg =>
msg.id === id ? { ...msg, read: true } : msg
))
}
const markAllAsRead = () => {
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
}
// 根据消息类型跳转到对应页面
const navigateByMessage = (message: Message) => {
// 标记已读
markAsRead(message.id)
// 根据消息类型决定跳转目标
switch (message.type) {
case 'invite':
// 邀请消息不跳转,在卡片内有操作按钮
break
case 'new_task':
// 新任务 -> 跳转到Brief查看页
if (message.taskId) router.push(`/creator/task/${message.taskId}/brief`)
break
case 'task_deadline':
// 任务截止提醒 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'brief_updated':
// Brief更新 -> 跳转到Brief查看页
if (message.taskId) router.push(`/creator/task/${message.taskId}/brief`)
break
case 'pass':
// 审核通过 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'need_fix':
// 需要修改 -> 跳转到任务详情(查看问题)
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'ai_complete':
// AI审核完成 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'agency_pass':
// 代理商审核通过 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'agency_reject':
// 代理商审核驳回 -> 跳转到任务详情(查看驳回原因)
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'brand_pass':
// 品牌方审核通过 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'brand_reject':
// 品牌方审核驳回 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'video_ai':
// 视频AI审核完成 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'appeal':
// 申诉结果 -> 跳转到申诉中心
router.push('/creator/appeals')
break
case 'appeal_success':
case 'appeal_failed':
// 申诉成功/失败 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
else router.push('/creator/appeals')
break
case 'appeal_quota_approved':
case 'appeal_quota_rejected':
// 申诉次数申请结果 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
else router.push('/creator/appeal-quota')
break
case 'video_agency_reject':
// 视频代理商驳回 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
case 'video_brand_reject':
// 视频品牌方驳回 -> 跳转到任务详情
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
break
default:
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
}
}
const handleAcceptInvite = (messageId: string) => {
setConfirmModal({ isOpen: true, type: 'accept', messageId })
}
const handleIgnoreInvite = (messageId: string) => {
setConfirmModal({ isOpen: true, type: 'ignore', messageId })
}
const handleConfirmAction = () => {
const { type, messageId } = confirmModal
setConfirmModal({ ...confirmModal, isOpen: false })
// 更新消息状态
setMessages(prev => prev.map(msg =>
msg.id === messageId ? { ...msg, hasActions: false, read: true } : msg
))
// 显示成功提示
setSuccessModal({
isOpen: true,
message: type === 'accept'
? '已成功接受邀请!您现在可以接收该代理商分配的推广任务了。'
: '已忽略该邀请。如需重新查看,请联系代理商。',
})
}
// 获取当前确认弹窗的代理商名称
const getAgencyName = () => {
const message = messages.find(m => m.id === confirmModal.messageId)
if (message?.content.includes('「')) {
const match = message.content.match(/「([^」]+)」/)
return match ? match[1] : '该代理商'
}
return '该代理商'
}
return (
<ResponsiveLayout role="creator">
<div className="flex flex-col gap-6 h-full">
{/* 顶部栏 */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-xl lg:text-[24px] font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary"></p>
</div>
<button
type="button"
className="px-4 py-2.5 rounded-lg bg-bg-elevated text-text-primary text-sm"
onClick={markAllAsRead}
>
</button>
</div>
{/* 消息列表 - 可滚动 */}
<div className="bg-bg-card rounded-2xl p-4 lg:p-6 flex-1 overflow-hidden">
<div className="flex flex-col gap-4 h-full overflow-y-auto pr-2">
{messages.map((message) => (
<MessageCard
key={message.id}
message={message}
onRead={() => markAsRead(message.id)}
onNavigate={() => navigateByMessage(message)}
onViewBrief={() => {
if (message.taskId) router.push(`/creator/task/${message.taskId}/brief`)
}}
onAcceptInvite={() => handleAcceptInvite(message.id)}
onIgnoreInvite={() => handleIgnoreInvite(message.id)}
/>
))}
</div>
</div>
</div>
{/* 确认弹窗 */}
<InviteConfirmModal
isOpen={confirmModal.isOpen}
type={confirmModal.type}
agencyName={getAgencyName()}
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
onConfirm={handleConfirmAction}
/>
{/* 成功提示弹窗 */}
<SuccessModal
isOpen={successModal.isOpen}
message={successModal.message}
onClose={() => setSuccessModal({ ...successModal, isOpen: false })}
/>
</ResponsiveLayout>
)
}