- Brief 支持代理商附件上传 (迁移 007) - 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息 - 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题 - 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护 - 项目创建时自动发送消息通知 - .gitignore 排除 backend/data/ 数据库文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||
import { api } from '@/lib/api'
|
||
import { Card, CardContent } from '@/components/ui/Card'
|
||
import { Button } from '@/components/ui/Button'
|
||
import {
|
||
Bell,
|
||
CheckCircle,
|
||
XCircle,
|
||
AlertTriangle,
|
||
FileText,
|
||
Video,
|
||
Users,
|
||
Clock,
|
||
Check,
|
||
MoreVertical,
|
||
Building2,
|
||
FolderPlus,
|
||
Settings,
|
||
MessageCircle,
|
||
CalendarClock,
|
||
Megaphone,
|
||
FileCheck,
|
||
UserCheck,
|
||
Eye
|
||
} from 'lucide-react'
|
||
import { getPlatformInfo } from '@/lib/platforms'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
// 消息类型
|
||
type MessageType =
|
||
| 'agency_review_pass' // 代理商审核通过,待品牌终审
|
||
| 'script_pending' // 新脚本待终审
|
||
| 'video_pending' // 新视频待终审
|
||
| 'project_created' // 项目创建成功
|
||
| 'agency_accept' // 代理商接受项目邀请
|
||
| 'creators_assigned' // 代理商配置达人到项目
|
||
| 'content_published' // 内容已发布
|
||
| 'rule_updated' // 规则更新生效
|
||
| 'review_timeout' // 审核超时提醒
|
||
| 'creator_appeal' // 达人发起申诉
|
||
| 'brief_config_updated' // 代理商更新了Brief配置
|
||
| 'batch_review_done' // 批量审核完成
|
||
| 'system_notice' // 系统通知
|
||
| 'new_task' // 新任务分配
|
||
| 'pass' // 审核通过
|
||
| 'reject' // 审核驳回
|
||
| 'approve' // 审核批准
|
||
|
||
type Message = {
|
||
id: string
|
||
type: MessageType
|
||
title: string
|
||
content: string
|
||
time: string
|
||
read: boolean
|
||
platform?: string
|
||
projectId?: string
|
||
taskId?: string
|
||
agencyName?: string
|
||
hasAction?: boolean
|
||
actionType?: 'review' | 'view'
|
||
}
|
||
|
||
// 消息配置
|
||
const messageConfig: Record<MessageType, {
|
||
icon: React.ElementType
|
||
iconColor: string
|
||
bgColor: string
|
||
}> = {
|
||
agency_review_pass: { icon: FileCheck, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||
script_pending: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||
video_pending: { icon: Video, iconColor: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||
project_created: { icon: FolderPlus, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
agency_accept: { icon: UserCheck, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
creators_assigned: { icon: Users, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||
content_published: { icon: Megaphone, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
rule_updated: { icon: Settings, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
|
||
review_timeout: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
|
||
creator_appeal: { icon: MessageCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
|
||
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||
batch_review_done: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
||
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
|
||
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||
}
|
||
|
||
// 模拟消息数据
|
||
const mockMessages: Message[] = [
|
||
{
|
||
id: 'msg-001',
|
||
type: 'creators_assigned',
|
||
title: '达人已分配',
|
||
content: '「星辰传媒」已为【XX品牌618推广】项目配置了5位达人,可查看达人列表',
|
||
time: '5分钟前',
|
||
read: false,
|
||
platform: 'douyin',
|
||
agencyName: '星辰传媒',
|
||
projectId: 'proj-001',
|
||
hasAction: true,
|
||
actionType: 'view',
|
||
},
|
||
{
|
||
id: 'msg-002',
|
||
type: 'script_pending',
|
||
title: '脚本待终审',
|
||
content: '【XX品牌618推广】「星辰传媒」的达人「小美护肤」脚本已通过代理商审核,请进行终审',
|
||
time: '10分钟前',
|
||
read: false,
|
||
platform: 'xiaohongshu',
|
||
agencyName: '星辰传媒',
|
||
taskId: 'task-007',
|
||
hasAction: true,
|
||
actionType: 'review',
|
||
},
|
||
{
|
||
id: 'msg-003',
|
||
type: 'video_pending',
|
||
title: '视频待终审',
|
||
content: '【BB运动饮料】「光影传媒」的达人「健身教练王」视频已通过代理商审核,请进行终审',
|
||
time: '30分钟前',
|
||
read: false,
|
||
platform: 'bilibili',
|
||
agencyName: '光影传媒',
|
||
taskId: 'task-014',
|
||
hasAction: true,
|
||
actionType: 'review',
|
||
},
|
||
{
|
||
id: 'msg-003b',
|
||
type: 'creators_assigned',
|
||
title: '达人已分配',
|
||
content: '「光影传媒」已为【BB运动饮料】项目配置了3位达人,可查看达人列表',
|
||
time: '1小时前',
|
||
read: false,
|
||
platform: 'bilibili',
|
||
agencyName: '光影传媒',
|
||
projectId: 'proj-002',
|
||
hasAction: true,
|
||
actionType: 'view',
|
||
},
|
||
{
|
||
id: 'msg-004',
|
||
type: 'review_timeout',
|
||
title: '审核超时提醒',
|
||
content: '有5条内容已等待终审超过48小时,请及时处理避免影响达人创作进度',
|
||
time: '1小时前',
|
||
read: false,
|
||
hasAction: true,
|
||
actionType: 'review',
|
||
},
|
||
{
|
||
id: 'msg-005',
|
||
type: 'agency_accept',
|
||
title: '代理商已加入',
|
||
content: '「星辰传媒」已接受您的项目邀请,加入【XX品牌618推广】项目',
|
||
time: '2小时前',
|
||
read: true,
|
||
agencyName: '星辰传媒',
|
||
projectId: 'proj-001',
|
||
},
|
||
{
|
||
id: 'msg-007',
|
||
type: 'project_created',
|
||
title: '项目创建成功',
|
||
content: '您的项目【XX品牌618推广】已创建成功,可以开始邀请代理商参与',
|
||
time: '昨天 14:30',
|
||
read: true,
|
||
projectId: 'proj-001',
|
||
hasAction: true,
|
||
actionType: 'view',
|
||
},
|
||
{
|
||
id: 'msg-008',
|
||
type: 'content_published',
|
||
title: '内容已发布',
|
||
content: '【XX品牌618推广】项目已有12条视频发布,累计播放量达50万+',
|
||
time: '昨天 10:00',
|
||
read: true,
|
||
platform: 'douyin',
|
||
projectId: 'proj-001',
|
||
},
|
||
{
|
||
id: 'msg-009',
|
||
type: 'brief_config_updated',
|
||
title: 'Brief配置更新',
|
||
content: '「星辰传媒」更新了【XX品牌618推广】的Brief配置,新增3个卖点要求',
|
||
time: '昨天 09:15',
|
||
read: true,
|
||
agencyName: '星辰传媒',
|
||
projectId: 'proj-001',
|
||
hasAction: true,
|
||
actionType: 'view',
|
||
},
|
||
{
|
||
id: 'msg-010',
|
||
type: 'creator_appeal',
|
||
title: '达人申诉通知',
|
||
content: '达人「美妆Lisa」对【新品口红试色】的驳回结果发起申诉,请关注处理进度',
|
||
time: '2天前',
|
||
read: true,
|
||
taskId: 'task-003',
|
||
},
|
||
{
|
||
id: 'msg-011',
|
||
type: 'rule_updated',
|
||
title: '规则更新生效',
|
||
content: '您配置的品牌规则【禁用竞品词库】已更新生效,新增15个违禁词',
|
||
time: '2天前',
|
||
read: true,
|
||
},
|
||
{
|
||
id: 'msg-012',
|
||
type: 'batch_review_done',
|
||
title: '批量审核完成',
|
||
content: '您今日已完成8条内容终审,其中通过6条,驳回2条',
|
||
time: '3天前',
|
||
read: true,
|
||
},
|
||
{
|
||
id: 'msg-013',
|
||
type: 'system_notice',
|
||
title: '系统通知',
|
||
content: '平台违禁词库已更新,新增「抖音」平台美妆类目违禁词56个',
|
||
time: '3天前',
|
||
read: true,
|
||
},
|
||
]
|
||
|
||
export default function BrandMessagesPage() {
|
||
const router = useRouter()
|
||
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
||
const [loading, setLoading] = useState(true)
|
||
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
||
|
||
const loadData = useCallback(async () => {
|
||
if (USE_MOCK) {
|
||
setLoading(false)
|
||
return
|
||
}
|
||
try {
|
||
const res = await api.getMessages({ page: 1, page_size: 50 })
|
||
const mapped: Message[] = res.items.map(item => ({
|
||
id: item.id,
|
||
type: (item.type || 'system_notice') as MessageType,
|
||
title: item.title,
|
||
content: item.content,
|
||
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
|
||
read: item.is_read,
|
||
taskId: item.related_task_id || undefined,
|
||
projectId: item.related_project_id || undefined,
|
||
}))
|
||
setMessages(mapped)
|
||
} catch {
|
||
// 加载失败保持 mock 数据
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { loadData() }, [loadData])
|
||
|
||
const unreadCount = messages.filter(m => !m.read).length
|
||
const pendingReviewCount = messages.filter(m =>
|
||
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
|
||
).length
|
||
|
||
const getFilteredMessages = () => {
|
||
switch (filter) {
|
||
case 'unread':
|
||
return messages.filter(m => !m.read)
|
||
case 'pending':
|
||
return messages.filter(m =>
|
||
m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending' || m.type === 'review_timeout'
|
||
)
|
||
default:
|
||
return messages
|
||
}
|
||
}
|
||
|
||
const filteredMessages = getFilteredMessages()
|
||
|
||
const markAsRead = async (id: string) => {
|
||
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
||
if (!USE_MOCK) {
|
||
try { await api.markMessageAsRead(id) } catch {}
|
||
}
|
||
}
|
||
|
||
const markAllAsRead = async () => {
|
||
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
||
if (!USE_MOCK) {
|
||
try { await api.markAllMessagesAsRead() } catch {}
|
||
}
|
||
}
|
||
|
||
const handleMessageClick = (message: Message) => {
|
||
if (message.type === 'system_notice') return // 系统通知不跳转
|
||
markAsRead(message.id)
|
||
|
||
// 根据消息类型跳转
|
||
switch (message.type) {
|
||
case 'script_pending':
|
||
if (message.taskId) router.push(`/brand/review/script/${message.taskId}`)
|
||
else router.push('/brand/review')
|
||
break
|
||
case 'video_pending':
|
||
if (message.taskId) router.push(`/brand/review/video/${message.taskId}`)
|
||
else router.push('/brand/review')
|
||
break
|
||
case 'creator_appeal':
|
||
// 达人申诉 -> 终审台
|
||
router.push('/brand/review')
|
||
break
|
||
case 'rule_updated':
|
||
// 规则更新 -> 规则配置
|
||
router.push('/brand/rules')
|
||
break
|
||
case 'batch_review_done':
|
||
// 批量审核完成 -> 终审台
|
||
router.push('/brand/review')
|
||
break
|
||
case 'agency_review_pass':
|
||
case 'review_timeout':
|
||
// 待终审内容 -> 终审台
|
||
router.push('/brand/review')
|
||
break
|
||
default:
|
||
if (message.projectId) {
|
||
router.push(`/brand/projects/${message.projectId}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleAction = (message: Message, e: React.MouseEvent) => {
|
||
e.stopPropagation()
|
||
markAsRead(message.id)
|
||
|
||
if (message.actionType === 'review') {
|
||
if (message.taskId) {
|
||
if (message.type === 'script_pending') {
|
||
router.push(`/brand/review/script/${message.taskId}`)
|
||
} else {
|
||
router.push(`/brand/review/video/${message.taskId}`)
|
||
}
|
||
} else {
|
||
router.push('/brand/review')
|
||
}
|
||
} else if (message.actionType === 'view') {
|
||
if (message.projectId) {
|
||
router.push(`/brand/projects/${message.projectId}`)
|
||
} else if (message.type === 'rule_updated') {
|
||
router.push('/brand/rules')
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 页面标题 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="text-2xl font-bold text-text-primary">消息中心</h1>
|
||
{unreadCount > 0 && (
|
||
<span className="px-2.5 py-1 bg-accent-coral/20 text-accent-coral text-sm font-medium rounded-lg">
|
||
{unreadCount} 条未读
|
||
</span>
|
||
)}
|
||
{pendingReviewCount > 0 && (
|
||
<span className="px-2.5 py-1 bg-accent-indigo/20 text-accent-indigo text-sm font-medium rounded-lg">
|
||
{pendingReviewCount} 条待审
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Button variant="secondary" onClick={markAllAsRead} disabled={unreadCount === 0}>
|
||
<Check size={16} />
|
||
全部已读
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 筛选标签 */}
|
||
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg w-fit">
|
||
<button
|
||
type="button"
|
||
onClick={() => setFilter('all')}
|
||
className={cn(
|
||
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||
filter === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||
)}
|
||
>
|
||
全部消息
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFilter('unread')}
|
||
className={cn(
|
||
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||
filter === 'unread' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||
)}
|
||
>
|
||
未读 ({unreadCount})
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFilter('pending')}
|
||
className={cn(
|
||
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
|
||
filter === 'pending' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||
)}
|
||
>
|
||
待处理
|
||
</button>
|
||
</div>
|
||
|
||
{/* 消息列表 */}
|
||
<div className="space-y-3">
|
||
{filteredMessages.map((message) => {
|
||
const config = messageConfig[message.type] || messageConfig.system_notice
|
||
const Icon = config.icon
|
||
const platform = message.platform ? getPlatformInfo(message.platform) : null
|
||
|
||
return (
|
||
<Card
|
||
key={message.id}
|
||
className={cn(
|
||
'transition-all overflow-hidden cursor-pointer hover:border-accent-indigo/50',
|
||
!message.read && 'border-l-4 border-l-accent-indigo'
|
||
)}
|
||
onClick={() => handleMessageClick(message)}
|
||
>
|
||
{/* 平台顶部条 */}
|
||
{platform && (
|
||
<div className={cn('px-4 py-1.5 border-b flex items-center gap-1.5', platform.bgColor, platform.borderColor)}>
|
||
<span className="text-sm">{platform.icon}</span>
|
||
<span className={cn('text-xs font-medium', platform.textColor)}>{platform.name}</span>
|
||
</div>
|
||
)}
|
||
|
||
<CardContent className="py-4">
|
||
<div className="flex items-start gap-4">
|
||
{/* 图标 */}
|
||
<div className={cn('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', config.bgColor)}>
|
||
<Icon size={20} className={config.iconColor} />
|
||
</div>
|
||
|
||
{/* 内容 */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className={cn('font-medium', !message.read ? 'text-text-primary' : 'text-text-secondary')}>
|
||
{message.title}
|
||
</h3>
|
||
{!message.read && (
|
||
<span className="w-2 h-2 bg-accent-coral rounded-full" />
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-text-secondary mt-1">{message.content}</p>
|
||
<div className="flex items-center justify-between mt-2">
|
||
<p className="text-xs text-text-tertiary flex items-center gap-1">
|
||
<Clock size={12} />
|
||
{message.time}
|
||
</p>
|
||
|
||
{/* 操作按钮 */}
|
||
{message.hasAction && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={(e) => handleAction(message, e)}
|
||
>
|
||
{message.actionType === 'review' ? (
|
||
<>
|
||
<Eye size={14} />
|
||
去审核
|
||
</>
|
||
) : (
|
||
<>
|
||
<Eye size={14} />
|
||
查看
|
||
</>
|
||
)}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 空状态 */}
|
||
{filteredMessages.length === 0 && (
|
||
<div className="text-center py-16">
|
||
<Bell size={48} className="mx-auto text-text-tertiary opacity-50 mb-4" />
|
||
<p className="text-text-secondary">
|
||
{filter === 'unread' ? '没有未读消息' : filter === 'pending' ? '没有待处理消息' : '暂无消息'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|