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

633 lines
21 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, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import {
Bell,
CheckCircle,
XCircle,
AlertTriangle,
FileText,
Video,
Users,
Clock,
Check,
MoreVertical,
PlusCircle,
UserCheck,
UserX,
Bot,
Settings,
CalendarClock,
Building2,
Eye
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { cn } from '@/lib/utils'
// 消息类型
type MessageType =
| 'appeal_quota_request' // 达人申请增加申诉次数
| 'task_submitted' // 达人提交了脚本/视频
| 'review_complete' // 品牌终审通过
| 'review_rejected' // 品牌终审驳回
| 'new_project' // 被品牌邀请参与项目
| 'warning' // 风险预警
| 'creator_accept' // 达人接受签约邀请
| 'creator_reject' // 达人拒绝签约邀请
| 'ai_review_complete' // AI审核完成待代理商审核
| 'brand_config_updated' // 品牌方更新了配置
| 'task_deadline' // 任务截止提醒
| 'brand_brief_updated' // 品牌方更新了Brief
| 'system_notice' // 系统通知
| 'new_task' // 新任务
| 'pass' // 审核通过
| 'reject' // 审核驳回
| 'force_pass' // 强制通过
| 'approve' // 审核批准
interface Message {
id: string
type: MessageType
title: string
content: string
time: string
read: boolean
icon: typeof Bell
iconColor: string
bgColor: string
platform?: string
taskId?: string
projectId?: string
creatorName?: string
hasAction?: boolean
actionType?: 'review' | 'view' | 'config'
// 申诉次数请求专用字段
appealRequest?: {
creatorName: string
taskName: string
taskId: string
status: 'pending' | 'approved' | 'rejected'
}
}
// 模拟消息列表
const mockMessages: Message[] = [
{
id: 'msg-001',
type: 'appeal_quota_request',
title: '申诉次数申请',
content: '达人「李小红」申请增加「618美妆推广视频」的申诉次数',
time: '5分钟前',
read: false,
icon: PlusCircle,
iconColor: 'text-accent-amber',
bgColor: 'bg-accent-amber/20',
platform: 'douyin',
appealRequest: {
creatorName: '李小红',
taskName: '618美妆推广视频',
taskId: 'task-001',
status: 'pending',
},
},
{
id: 'msg-002',
type: 'ai_review_complete',
title: 'AI审核完成',
content: '达人「小美护肤」的脚本【夏日护肤推广】已通过AI审核请及时进行人工审核',
time: '10分钟前',
read: false,
icon: Bot,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
platform: 'xiaohongshu',
taskId: 'task-006',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-003',
type: 'creator_accept',
title: '达人已签约',
content: '达人「美妆达人小王」已接受您的签约邀请,可以开始分配任务',
time: '20分钟前',
read: false,
icon: UserCheck,
iconColor: 'text-accent-green',
bgColor: 'bg-accent-green/20',
creatorName: '美妆达人小王',
},
{
id: 'msg-004',
type: 'task_submitted',
title: '新脚本提交',
content: '达人「小美护肤」提交了「夏日护肤推广脚本」,请及时审核',
time: '30分钟前',
read: false,
icon: FileText,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
platform: 'xiaohongshu',
taskId: 'task-006',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-005',
type: 'appeal_quota_request',
title: '申诉次数申请',
content: '达人「美妆达人小王」申请增加「双11护肤品种草」的申诉次数',
time: '30分钟前',
read: false,
icon: PlusCircle,
iconColor: 'text-accent-amber',
bgColor: 'bg-accent-amber/20',
platform: 'xiaohongshu',
appealRequest: {
creatorName: '美妆达人小王',
taskName: '双11护肤品种草',
taskId: 'task-002',
status: 'pending',
},
},
{
id: 'msg-006',
type: 'task_deadline',
title: '任务即将截止',
content: '【XX品牌618推广】任务将于3天后截止还有5位达人未提交脚本',
time: '1小时前',
read: false,
icon: CalendarClock,
iconColor: 'text-orange-400',
bgColor: 'bg-orange-500/20',
platform: 'douyin',
hasAction: true,
actionType: 'view',
},
{
id: 'msg-007',
type: 'review_complete',
title: '品牌终审通过',
content: '【XX品牌618推广】达人「美妆小红」的视频「新品口红试色」已通过品牌方终审可通知达人发布',
time: '1小时前',
read: false,
icon: CheckCircle,
iconColor: 'text-accent-green',
bgColor: 'bg-accent-green/20',
platform: 'xiaohongshu',
taskId: 'task-004',
},
{
id: 'msg-008',
type: 'review_rejected',
title: '品牌终审驳回',
content: '【BB运动饮料】达人「健身教练王」的视频「健身器材开箱」被品牌方驳回原因违禁词使用请通知达人修改',
time: '2小时前',
read: false,
icon: XCircle,
iconColor: 'text-accent-coral',
bgColor: 'bg-accent-coral/20',
platform: 'bilibili',
taskId: 'task-010',
},
{
id: 'msg-009',
type: 'brand_config_updated',
title: '品牌规则更新',
content: '品牌方「XX护肤品牌」更新了违禁词配置新增8个违禁词',
time: '3小时前',
read: true,
icon: Settings,
iconColor: 'text-accent-amber',
bgColor: 'bg-accent-amber/20',
hasAction: true,
actionType: 'config',
},
{
id: 'msg-010',
type: 'brand_brief_updated',
title: 'Brief更新通知',
content: '品牌方更新了【XX品牌618推广】的Brief要求请查看最新内容',
time: '4小时前',
read: true,
icon: FileText,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
projectId: 'proj-001',
hasAction: true,
actionType: 'view',
},
{
id: 'msg-011',
type: 'creator_reject',
title: '达人已拒绝',
content: '达人「时尚博主Anna」拒绝了您的签约邀请',
time: '昨天 14:30',
read: true,
icon: UserX,
iconColor: 'text-accent-coral',
bgColor: 'bg-accent-coral/20',
creatorName: '时尚博主Anna',
},
{
id: 'msg-012',
type: 'new_project',
title: '新项目邀请',
content: '您被邀请参与「XX品牌新品推广」项目请配置 Brief',
time: '昨天',
read: true,
icon: Building2,
iconColor: 'text-purple-400',
bgColor: 'bg-purple-500/20',
platform: 'douyin',
projectId: 'proj-001',
hasAction: true,
actionType: 'config',
},
{
id: 'msg-013',
type: 'warning',
title: '风险预警',
content: '达人「美妆Lisa」连续2次提交被驳回建议关注并提供指导',
time: '昨天',
read: true,
icon: AlertTriangle,
iconColor: 'text-orange-400',
bgColor: 'bg-orange-500/20',
platform: 'xiaohongshu',
creatorName: '美妆Lisa',
},
{
id: 'msg-015',
type: 'task_submitted',
title: '新视频提交',
content: '达人「健身教练王」提交了「健身器材使用教程」视频,请及时审核',
time: '2天前',
read: true,
icon: Video,
iconColor: 'text-purple-400',
bgColor: 'bg-purple-500/20',
platform: 'bilibili',
taskId: 'task-009',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-016',
type: 'system_notice',
title: '系统通知',
content: '平台违禁词库已更新「抖音」平台新增美妆类目违禁词56个',
time: '3天前',
read: true,
icon: Bell,
iconColor: 'text-text-secondary',
bgColor: 'bg-bg-elevated',
},
]
export default function AgencyMessagesPage() {
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 typeIconMap: Record<string, { icon: typeof Bell; iconColor: string; bgColor: string }> = {
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' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
}
const defaultIcon = { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' }
const mapped: Message[] = res.items.map(item => {
const iconCfg = typeIconMap[item.type] || defaultIcon
return {
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,
icon: iconCfg.icon,
iconColor: iconCfg.iconColor,
bgColor: iconCfg.bgColor,
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 pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
const pendingReviewCount = messages.filter(m =>
!m.read && (m.type === 'task_submitted' || m.type === 'ai_review_complete')
).length
const getFilteredMessages = () => {
switch (filter) {
case 'unread':
return messages.filter(m => !m.read)
case 'pending':
return messages.filter(m =>
m.type === 'task_submitted' || m.type === 'ai_review_complete' || m.type === 'appeal_quota_request'
)
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 handleAppealRequest = (messageId: string, action: 'approve' | 'reject') => {
setMessages(prev => prev.map(m => {
if (m.id === messageId && m.appealRequest) {
return {
...m,
read: true,
appealRequest: {
...m.appealRequest,
status: action === 'approve' ? 'approved' : 'rejected',
},
}
}
return m
}))
}
// 处理消息点击
const handleMessageClick = (message: Message) => {
if (message.type === 'appeal_quota_request') return // 申诉请求不跳转
if (message.type === 'system_notice') return // 系统通知不跳转
markAsRead(message.id)
// 根据消息类型决定跳转
switch (message.type) {
case 'creator_accept':
case 'creator_reject':
// 达人签约相关 -> 达人管理
router.push('/agency/creators')
break
case 'warning':
// 风险预警 -> 达人管理
router.push('/agency/creators')
break
case 'brand_config_updated':
// 品牌规则更新 -> Brief配置
router.push('/agency/briefs')
break
case 'task_deadline':
// 任务截止提醒 -> 任务列表
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else {
router.push('/agency/review')
}
break
default:
// 默认逻辑
if (message.taskId) {
router.push(`/agency/review/${message.taskId}`)
} else if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
}
}
}
// 处理操作按钮点击
const handleAction = (message: Message, e: React.MouseEvent) => {
e.stopPropagation()
markAsRead(message.id)
switch (message.actionType) {
case 'review':
if (message.taskId) {
router.push(`/agency/review/${message.taskId}`)
} else {
router.push('/agency/review')
}
break
case 'view':
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else if (message.type === 'task_deadline') {
router.push('/agency/review')
} else {
router.push('/agency/creators')
}
break
case 'config':
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else {
router.push('/agency/briefs')
}
break
}
}
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 py-1 bg-accent-coral/20 text-accent-coral text-sm font-medium rounded-lg">
{unreadCount}
</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 Icon = message.icon
const isAppealRequest = message.type === 'appeal_quota_request'
const appealStatus = message.appealRequest?.status
const platform = message.platform ? getPlatformInfo(message.platform) : null
return (
<Card
key={message.id}
className={cn(
'transition-all overflow-hidden',
!isAppealRequest && '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', message.bgColor)}>
<Icon size={20} className={message.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" />
)}
{/* 申诉请求状态标签 */}
{isAppealRequest && appealStatus === 'approved' && (
<span className="px-2 py-0.5 bg-accent-green/15 text-accent-green text-xs font-medium rounded-full">
</span>
)}
{isAppealRequest && appealStatus === 'rejected' && (
<span className="px-2 py-0.5 bg-accent-coral/15 text-accent-coral text-xs font-medium rounded-full">
</span>
)}
</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 && !isAppealRequest && (
<Button
variant="secondary"
size="sm"
onClick={(e) => handleAction(message, e)}
>
<Eye size={14} />
{message.actionType === 'review' ? '去审核' : message.actionType === 'config' ? '去配置' : '查看'}
</Button>
)}
</div>
{/* 申诉次数请求操作按钮 */}
{isAppealRequest && appealStatus === 'pending' && (
<div className="flex items-center gap-2 mt-3">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleAppealRequest(message.id, 'approve')
}}
>
<CheckCircle size={14} />
(+1)
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleAppealRequest(message.id, 'reject')
}}
>
<XCircle 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' ? '没有未读消息' : '暂无消息'}
</p>
</div>
)}
</div>
)
}