feat(creator): 完成达人端前端页面开发

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-06 15:38:01 +08:00
co-authored by Claude Opus 4.5
parent 1011e73643
commit 2f9b7f05fd
18 changed files with 5638 additions and 1145 deletions
+477 -192
View File
@@ -1,244 +1,529 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Bell, CheckCircle, XCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react'
import { DesktopLayout } from '@/components/layout/DesktopLayout'
import { MobileLayout } from '@/components/layout/MobileLayout'
import {
UserPlus,
ClipboardList,
CheckCircle,
PenLine,
ScanSearch,
Building2,
XCircle,
BadgeCheck,
Video,
MessageCircle,
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
type MessageStatus = 'info' | 'success' | 'error'
// 消息类型
type MessageType =
| 'invite' // 代理商邀请
| 'new_task' // 新任务分配
| 'pass' // 审核通过
| 'need_fix' // 需要修改
| 'ai_complete' // AI审核完成
| 'agency_pass' // 代理商审核通过
| 'agency_reject' // 代理商审核驳回
| 'brand_pass' // 品牌方审核通过
| 'brand_reject' // 品牌方审核驳回
| 'video_ai' // 视频AI审核完成
| 'appeal' // 申诉结果
| 'video_agency_reject' // 视频代理商驳回
| 'video_brand_reject' // 视频品牌方驳回
const mockMessages = [
type Message = {
id: string
type: MessageType
title: string
content: string
time: string
read: boolean
taskId?: string
hasActions?: boolean // 是否有操作按钮(邀请类型)
}
// 消息配置
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' },
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' },
}
// 12条消息数据
const mockMessages: Message[] = [
{
id: 'msg-001',
taskId: 'task-002',
title: 'AI 审核通过',
description: '已进入代理商审核,请等待最终结果。',
time: '刚刚',
status: 'success' as MessageStatus,
type: 'invite',
title: '代理商邀请',
content: '「星辰传媒」邀请您成为签约达人,加入后可接收该代理商分配的推广任务',
time: '5分钟前',
read: false,
hasActions: true,
},
{
id: 'msg-002',
taskId: 'task-003',
title: 'AI 审核未通过',
description: '检测到竞品 Logo 与绝对化用语,请修改后重新提交。',
time: '10 分钟前',
status: 'error' as MessageStatus,
type: 'new_task',
title: '新任务分配',
content: '您有一个新任务【XX品牌618推广】,请在3天内提交脚本',
time: '10分钟前',
read: false,
taskId: 'task-001',
},
{
id: 'msg-003',
taskId: 'task-001',
title: '等待提交脚本',
description: '请先上传脚本,系统才能开始合规预审。',
time: '1 小时前',
status: 'info' as MessageStatus,
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',
title: '申诉结果通知',
content: '您的申诉已通过,AI已学习您的反馈,感谢您帮助我们改进系统',
time: '3天前',
read: false,
},
{
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',
},
]
const statusConfig: Record<MessageStatus, { icon: React.ElementType; color: string; bg: string }> = {
success: { icon: CheckCircle, color: 'text-accent-green', bg: 'bg-accent-green/15' },
error: { icon: XCircle, color: 'text-accent-coral', bg: 'bg-accent-coral/15' },
info: { icon: Clock, color: 'text-accent-indigo', bg: 'bg-accent-indigo/15' },
}
// 消息卡片组件
function MessageCard({
title,
description,
time,
status,
isRead,
onClick,
message,
onRead,
onNavigate,
onAcceptInvite,
onIgnoreInvite,
}: {
title: string
description: string
time: string
status: MessageStatus
isRead: boolean
onClick: () => void
message: Message
onRead: () => void
onNavigate: () => void
onAcceptInvite?: () => void
onIgnoreInvite?: () => void
}) {
const Icon = statusConfig[status].icon
const config = messageConfig[message.type]
const Icon = config.icon
return (
<button
type="button"
onClick={onClick}
<div
className={cn(
'w-full text-left bg-bg-card rounded-xl p-4 flex items-start gap-4 card-shadow hover:bg-bg-elevated/50 transition-colors',
!isRead && 'ring-1 ring-accent-indigo/20'
'rounded-xl p-4 flex gap-4 cursor-pointer transition-colors',
message.read
? 'bg-transparent border border-bg-elevated'
: 'bg-bg-elevated'
)}
onClick={() => {
onRead()
if (message.taskId) onNavigate()
}}
>
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center', statusConfig[status].bg)}>
<Icon className={cn('w-5 h-5', statusConfig[status].color)} />
{/* 图标 */}
<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-1">
{/* 内容 */}
<div className="flex-1 flex flex-col gap-2">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('text-sm', isRead ? 'text-text-primary' : 'text-text-primary font-semibold')}>
{title}
</span>
{!isRead && <span className="w-2 h-2 rounded-full bg-accent-indigo" />}
</div>
<span className="text-xs text-text-tertiary">{time}</span>
<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">{description}</p>
{/* 描述 */}
<p className="text-sm text-text-secondary leading-relaxed">{message.content}</p>
{/* 邀请类型的操作按钮 */}
{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>
</button>
{/* 未读标记 */}
{!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 [isMobile, setIsMobile] = useState(true)
const [messages, setMessages] = useState(mockMessages)
const [showRead, setShowRead] = useState(false)
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: '',
})
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1024)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
const handleClick = (messageId: string, taskId: string) => {
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, read: true } : msg))
)
router.push(`/creator/task/${taskId}`)
const markAsRead = (id: string) => {
setMessages(prev => prev.map(msg =>
msg.id === id ? { ...msg, read: true } : msg
))
}
const unreadCount = messages.filter((msg) => !msg.read).length
const unreadMessages = messages.filter((msg) => !msg.read)
const readMessages = messages.filter((msg) => msg.read)
const DesktopContent = (
<DesktopLayout role="creator">
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':
// 新任务 -> 跳转到任务详情(上传脚本)
if (message.taskId) router.push(`/creator/task/${message.taskId}`)
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 '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">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
<Bell className="w-6 h-6 text-accent-indigo" />
</div>
<div>
<h1 className="text-[28px] font-bold text-text-primary"></h1>
<p className="text-[15px] text-text-secondary"> AI </p>
</div>
{/* 顶部栏 */}
<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>
<span className="text-sm text-text-tertiary"> {unreadCount}</span>
</div>
<div className="flex flex-col gap-4">
{unreadMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{unreadMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
<div className="pt-2">
<button
type="button"
onClick={() => setShowRead((prev) => !prev)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary"
>
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
({readMessages.length})
</button>
</div>
{showRead && readMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{showRead && readMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
</div>
</div>
</DesktopLayout>
)
const MobileContent = (
<MobileLayout role="creator">
<div className="flex flex-col gap-5 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-accent-indigo/15 flex items-center justify-center">
<Bell className="w-5 h-5 text-accent-indigo" />
</div>
<div>
<h1 className="text-xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary"></p>
</div>
</div>
<div className="flex flex-col gap-3">
{unreadMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{unreadMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
<button
type="button"
onClick={() => setShowRead((prev) => !prev)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary pt-1"
className="px-4 py-2.5 rounded-lg bg-bg-elevated text-text-primary text-sm"
onClick={markAllAsRead}
>
{showRead ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
({readMessages.length})
</button>
</div>
{showRead && readMessages.length === 0 && (
<div className="bg-bg-card rounded-xl p-4 text-sm text-text-tertiary">
</div>
)}
{showRead && readMessages.map((msg) => (
<MessageCard
key={msg.id}
title={msg.title}
description={msg.description}
time={msg.time}
status={msg.status}
isRead={msg.read}
onClick={() => handleClick(msg.id, msg.taskId)}
/>
))}
{/* 消息列表 - 可滚动 */}
<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)}
onAcceptInvite={() => handleAcceptInvite(message.id)}
onIgnoreInvite={() => handleIgnoreInvite(message.id)}
/>
))}
</div>
</div>
</div>
</MobileLayout>
)
return isMobile ? MobileContent : DesktopContent
{/* 确认弹窗 */}
<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>
)
}