Your Name 4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00

509 lines
17 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 } 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>
)
}