feat: 完善审核台文件预览与消息通知系统

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-09 12:20:47 +08:00
parent bbc8a4f641
commit a5a005db0c
16 changed files with 3251 additions and 269 deletions

94
backend/BACKEND_TODO.md Normal file
View File

@ -0,0 +1,94 @@
# 后端开发备忘
## 文件预览相关 API
### 1. 文件上传与存储
- 达人上传脚本文件(支持 .docx, .pdf, .xlsx, .txt 等)
- 达人上传视频文件(支持 .mp4, .mov, .webm 等)
- 文件存储到 OSS/S3返回访问 URL
### 2. 文件访问 API
```
GET /api/files/:fileId
返回:{ url: "文件访问URL", fileName, fileSize, fileType, uploadedAt }
```
### 3. 文件类型转换(可选,提升体验)
- Word (.docx) → PDF
- Excel (.xlsx) → PDF
- PPT (.pptx) → PDF
- 使用 LibreOffice 或 Pandoc 实现
### 4. 视频流服务
- 支持视频分段加载Range 请求)
- 支持视频缩略图生成
---
## 审核相关 API
### 脚本审核
```
GET /api/agency/review/scripts # 待审脚本列表
GET /api/agency/review/scripts/:id # 脚本详情含文件URL、AI分析结果
POST /api/agency/review/scripts/:id/approve # 通过
POST /api/agency/review/scripts/:id/reject # 驳回
POST /api/agency/review/scripts/:id/force-pass # 强制通过
```
### 视频审核
```
GET /api/agency/review/videos # 待审视频列表
GET /api/agency/review/videos/:id # 视频详情含文件URL、AI分析结果
POST /api/agency/review/videos/:id/approve # 通过
POST /api/agency/review/videos/:id/reject # 驳回
POST /api/agency/review/videos/:id/force-pass # 强制通过
```
### 品牌方终审
```
GET /api/brand/review/scripts # 待终审脚本列表
GET /api/brand/review/scripts/:id # 脚本详情
POST /api/brand/review/scripts/:id/approve # 终审通过
POST /api/brand/review/scripts/:id/reject # 终审驳回
GET /api/brand/review/videos # 待终审视频列表
GET /api/brand/review/videos/:id # 视频详情
POST /api/brand/review/videos/:id/approve # 终审通过
POST /api/brand/review/videos/:id/reject # 终审驳回
```
---
## 申诉相关字段
审核列表和详情需要包含:
- `isAppeal: boolean` - 是否为申诉
- `appealReason: string` - 申诉理由
- `appealCount: number` - 第几次申诉
---
## 文件数据结构
```typescript
interface FileInfo {
id: string
fileName: string
fileSize: string // "1.5 MB"
fileType: string // "video/mp4", "application/pdf", etc.
fileUrl: string // 访问URL
uploadedAt: string // ISO 时间
// 视频特有
duration?: number // 秒
thumbnail?: string // 缩略图URL
}
```
---
## 注意事项
1. 文件 URL 需要支持跨域访问CORS
2. 视频需要支持 Range 请求实现分段加载
3. 敏感文件考虑使用签名 URL有效期限制

View File

@ -4,30 +4,84 @@ import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag } from '@/components/ui/Tag'
import {
ArrowLeft,
FileText,
Upload,
Download,
Eye,
Target,
Ban,
AlertTriangle,
Sparkles,
FileDown,
CheckCircle,
Clock,
Building2,
Info,
Plus,
X,
Save,
Sparkles,
Target,
Ban,
AlertTriangle
Upload,
Trash2,
File
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟 Brief 详情
const mockBrief = {
// 文件类型
type BriefFile = {
id: string
name: string
type: 'brief' | 'rule' | 'reference'
size: string
uploadedAt: string
}
// 模拟品牌方 Brief只读
const mockBrandBrief = {
id: 'brief-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
platform: 'douyin',
// 品牌方上传的文件列表
files: [
{ id: 'f1', name: 'XX品牌618推广Brief.pdf', type: 'brief' as const, size: '2.3MB', uploadedAt: '2026-02-01' },
{ id: 'f2', name: '产品卖点说明.docx', type: 'reference' as const, size: '1.2MB', uploadedAt: '2026-02-01' },
{ id: 'f3', name: '品牌视觉指南.pdf', type: 'reference' as const, size: '5.8MB', uploadedAt: '2026-02-01' },
],
// 品牌方配置的规则(只读)
brandRules: {
restrictions: '不可提及竞品,不可使用绝对化用语',
competitors: ['安耐晒', '资生堂', '兰蔻'],
},
}
// 代理商上传的Brief文档可编辑
type AgencyFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 代理商自己的配置(可编辑)
const mockAgencyConfig = {
status: 'configured',
fileName: 'XX品牌618推广Brief.pdf',
uploadedAt: '2026-02-01',
configuredAt: '2026-02-02',
// 代理商上传的Brief文档给达人看的
agencyFiles: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
] as AgencyFile[],
// AI 解析出的内容
aiParsedContent: {
productName: 'XX品牌防晒霜',
targetAudience: '18-35岁女性',
contentRequirements: '需展示产品质地、使用效果视频时长30-60秒',
},
// 代理商配置的卖点(可编辑)
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
@ -35,40 +89,104 @@ const mockBrief = {
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
// 代理商配置的违禁词(可编辑)
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
aiParsedContent: {
productName: 'XX品牌防晒霜',
targetAudience: '18-35岁女性',
contentRequirements: '需展示产品质地、使用效果',
restrictions: '不可提及竞品,不可使用绝对化用语',
}
// 平台规则
const platformRules = {
douyin: {
name: '抖音',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
{ category: '医疗相关禁用', items: ['治疗', '药用', '医学', '临床', '处方'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用', '立竿见影'] },
],
},
xiaohongshu: {
name: '小红书',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极品', '绝对'] },
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
],
},
bilibili: {
name: 'B站',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用'] },
],
},
}
export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const [brief, setBrief] = useState(mockBrief)
// 品牌方 Brief只读
const [brandBrief] = useState(mockBrandBrief)
// 代理商配置(可编辑)
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
const [isAIParsing, setIsAIParsing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// 弹窗状态
const [showFilesModal, setShowFilesModal] = useState(false)
const [showAgencyFilesModal, setShowAgencyFilesModal] = useState(false)
const [previewFile, setPreviewFile] = useState<BriefFile | null>(null)
const [previewAgencyFile, setPreviewAgencyFile] = useState<AgencyFile | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const platform = getPlatformInfo(brandBrief.platform)
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
// 下载文件
const handleDownload = (file: BriefFile) => {
alert(`下载文件: ${file.name}`)
}
// 预览文件
const handlePreview = (file: BriefFile) => {
setPreviewFile(file)
}
// 导出平台规则文档
const handleExportRules = async () => {
setIsExporting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsExporting(false)
alert('平台规则文档已导出!')
}
// AI 解析
const handleAIParse = async () => {
setIsAIParsing(true)
// 模拟 AI 解析
await new Promise(resolve => setTimeout(resolve, 2000))
setIsAIParsing(false)
alert('AI 解析完成!')
}
// 保存配置
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存!')
}
// 卖点操作
const addSellingPoint = () => {
if (!newSellingPoint.trim()) return
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }]
}))
@ -76,14 +194,14 @@ export default function BriefConfigPage() {
}
const removeSellingPoint = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: prev.sellingPoints.filter(sp => sp.id !== id)
}))
}
const toggleRequired = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: prev.sellingPoints.map(sp =>
sp.id === id ? { ...sp, required: !sp.required } : sp
@ -91,9 +209,10 @@ export default function BriefConfigPage() {
}))
}
// 违禁词操作
const addBlacklistWord = () => {
if (!newBlacklistWord.trim()) return
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
blacklistWords: [...prev.blacklistWords, { id: `bw${Date.now()}`, word: newBlacklistWord, reason: '自定义' }]
}))
@ -101,17 +220,45 @@ export default function BriefConfigPage() {
}
const removeBlacklistWord = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
blacklistWords: prev.blacklistWords.filter(bw => bw.id !== id)
}))
}
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存!')
// 代理商文档操作
const handleUploadAgencyFile = async () => {
setIsUploading(true)
// 模拟上传
await new Promise(resolve => setTimeout(resolve, 1500))
const newFile: AgencyFile = {
id: `af${Date.now()}`,
name: '新上传文档.pdf',
size: '1.2MB',
uploadedAt: new Date().toISOString().split('T')[0],
description: '新上传的文档'
}
setAgencyConfig(prev => ({
...prev,
agencyFiles: [...prev.agencyFiles, newFile]
}))
setIsUploading(false)
alert('文档上传成功!')
}
const removeAgencyFile = (id: string) => {
setAgencyConfig(prev => ({
...prev,
agencyFiles: prev.agencyFiles.filter(f => f.id !== id)
}))
}
const handlePreviewAgencyFile = (file: AgencyFile) => {
setPreviewAgencyFile(file)
}
const handleDownloadAgencyFile = (file: AgencyFile) => {
alert(`下载文件: ${file.name}`)
}
return (
@ -122,48 +269,214 @@ export default function BriefConfigPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{brief.projectName}</h1>
<p className="text-sm text-text-secondary">{brief.brandName}</p>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-text-primary">{brandBrief.projectName}</h1>
{platform && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
</div>
<p className="text-sm text-text-secondary flex items-center gap-2 mt-1">
<Building2 size={14} />
{brandBrief.brandName}
</p>
</div>
<Button variant="secondary" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={16} />
{isExporting ? '导出中...' : '导出规则'}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
<Save size={16} />
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
{/* ===== 第一部分:品牌方 Brief只读===== */}
<div className="p-4 bg-purple-500/10 rounded-lg border border-purple-500/30">
<div className="flex items-start gap-3">
<Building2 size={20} className="text-purple-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-purple-400 font-medium"> Brief</p>
<p className="text-sm text-purple-400/80 mt-1">
Brief
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧Brief 文件和 AI 解析 */}
<div className="lg:col-span-2 space-y-6">
{/* Brief 文件 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
Brief
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
{/* 品牌方文件 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<FileText size={18} className="text-purple-400" />
Brief
<span className="text-sm font-normal text-text-secondary">
{brandBrief.files.length}
</span>
</span>
<Button variant="secondary" size="sm" onClick={() => setShowFilesModal(true)}>
<Eye size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{brandBrief.files.slice(0, 2).map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<FileText size={32} className="text-accent-indigo" />
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={20} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary">{brief.fileName}</p>
<p className="text-sm text-text-secondary"> {brief.uploadedAt}</p>
<p className="font-medium text-text-primary text-sm">{file.name}</p>
<p className="text-xs text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm">
<Upload size={14} />
<Button variant="ghost" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{brandBrief.files.length > 2 && (
<button
type="button"
onClick={() => setShowFilesModal(true)}
className="w-full p-3 text-sm text-purple-400 hover:bg-purple-500/5 rounded-lg transition-colors"
>
{brandBrief.files.length}
</button>
)}
</CardContent>
</Card>
{/* 品牌方规则(只读) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<p className="text-sm text-text-primary">{brandBrief.brandRules.restrictions}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<div className="flex flex-wrap gap-2">
{brandBrief.brandRules.competitors.map((c, i) => (
<span key={i} className="px-2 py-1 text-xs bg-orange-500/15 text-orange-400 rounded border border-orange-500/30">
{c}
</span>
))}
</div>
</div>
</CardContent>
</Card>
</div>
{/* ===== 第二部分:代理商配置(可编辑)===== */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<p className="text-sm text-accent-indigo/80 mt-1">
</p>
</div>
</div>
</div>
{/* 代理商Brief文档管理 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<File size={18} className="text-accent-indigo" />
Brief
<span className="text-sm font-normal text-text-secondary">
{agencyConfig.agencyFiles.length}
</span>
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowAgencyFilesModal(true)}>
<Eye size={14} />
</Button>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="p-4 bg-accent-indigo/5 rounded-lg border border-accent-indigo/20 hover:border-accent-indigo/40 transition-colors">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text-primary text-sm truncate">{file.name}</p>
<p className="text-xs text-text-tertiary mt-0.5">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Button variant="ghost" size="sm" onClick={() => handlePreviewAgencyFile(file)} className="flex-1">
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownloadAgencyFile(file)} className="flex-1">
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{/* 上传占位卡片 */}
<button
type="button"
onClick={handleUploadAgencyFile}
disabled={isUploading}
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
>
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
<Plus size={20} className="text-text-tertiary" />
</div>
<span className="text-sm text-text-secondary"></span>
</button>
</div>
<div className="mt-4 p-3 bg-accent-indigo/10 rounded-lg border border-accent-indigo/20">
<p className="text-xs text-accent-indigo flex items-center gap-2">
<Info size={14} />
</p>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧AI解析 + 卖点配置 */}
<div className="lg:col-span-2 space-y-6">
{/* AI 解析结果 */}
<Card>
<CardHeader>
@ -182,37 +495,33 @@ export default function BriefConfigPage() {
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.productName}</p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.productName}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.targetAudience}</p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.targetAudience}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg col-span-2">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.contentRequirements}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg col-span-2">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.restrictions}</p>
<p className="text-text-primary">{agencyConfig.aiParsedContent.contentRequirements}</p>
</div>
</div>
</CardContent>
</Card>
{/* 卖点配置 */}
{/* 卖点配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target size={18} className="text-accent-green" />
<span className="text-sm font-normal text-text-secondary ml-2">
{brief.sellingPoints.length}
{agencyConfig.sellingPoints.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{brief.sellingPoints.map((sp) => (
{agencyConfig.sellingPoints.map((sp) => (
<div key={sp.id} className="flex items-center gap-3 p-3 bg-bg-elevated rounded-lg">
<button
type="button"
@ -249,22 +558,53 @@ export default function BriefConfigPage() {
</div>
</CardContent>
</Card>
{/* 平台规则 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-amber" />
{rules.name}
</span>
<Button variant="secondary" size="sm" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rules.rules.map((rule, index) => (
<div key={index}>
<p className="text-sm font-medium text-text-primary mb-2">{rule.category}</p>
<div className="flex flex-wrap gap-2">
{rule.items.map((item, i) => (
<span key={i} className="px-2 py-1 text-xs bg-accent-amber/15 text-accent-amber rounded border border-accent-amber/30">
{item}
</span>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* 右侧:违禁词配置 */}
<div className="space-y-6">
{/* 违禁词配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Ban size={18} className="text-accent-coral" />
<span className="text-sm font-normal text-text-secondary ml-2">
{brief.blacklistWords.length}
{agencyConfig.blacklistWords.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{brief.blacklistWords.map((bw) => (
{agencyConfig.blacklistWords.map((bw) => (
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div>
<span className="font-medium text-accent-coral">{bw.word}</span>
@ -295,22 +635,193 @@ export default function BriefConfigPage() {
</CardContent>
</Card>
{/* 配置信息 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock size={18} className="text-text-tertiary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<SuccessTag></SuccessTag>
</div>
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<span className="text-text-primary">{agencyConfig.configuredAt}</span>
</div>
</CardContent>
</Card>
{/* 配置提示 */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="p-4 bg-accent-green/10 rounded-lg border border-accent-green/30">
<div className="flex items-start gap-3">
<AlertTriangle size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<CheckCircle size={20} className="text-accent-green flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<ul className="text-xs text-accent-indigo/80 mt-1 space-y-1">
<p className="text-sm text-accent-green font-medium"></p>
<ul className="text-xs text-accent-green/80 mt-1 space-y-1">
<li> </li>
<li> AI </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
</div>
{/* 文件列表弹窗 */}
<Modal
isOpen={showFilesModal}
onClose={() => setShowFilesModal(false)}
title="品牌方 Brief 文件"
size="lg"
>
<div className="space-y-3">
{brandBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={24} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
</div>
</Modal>
{/* 文件预览弹窗(品牌方) */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
{/* 代理商文档管理弹窗 */}
<Modal
isOpen={showAgencyFilesModal}
onClose={() => setShowAgencyFilesModal(false)}
title="管理代理商 Brief 文档"
size="lg"
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-text-secondary">
</p>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
<div className="space-y-3">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<FileText size={24} className="text-accent-indigo" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-tertiary mt-1">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreviewAgencyFile(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownloadAgencyFile(file)}>
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{agencyConfig.agencyFiles.length === 0 && (
<div className="py-12 text-center">
<File size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
)}
</div>
</div>
</Modal>
{/* 代理商文档预览弹窗 */}
<Modal
isOpen={!!previewAgencyFile}
onClose={() => setPreviewAgencyFile(null)}
title={previewAgencyFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewAgencyFile(null)}>
</Button>
{previewAgencyFile && (
<Button onClick={() => handleDownloadAgencyFile(previewAgencyFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
</div>
)
}

View File

@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
@ -15,14 +16,37 @@ import {
Clock,
Check,
MoreVertical,
PlusCircle
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' // 系统通知
interface Message {
id: string
type: string
type: MessageType
title: string
content: string
time: string
@ -31,6 +55,11 @@ interface Message {
iconColor: string
bgColor: string
platform?: string
taskId?: string
projectId?: string
creatorName?: string
hasAction?: boolean
actionType?: 'review' | 'view' | 'config'
// 申诉次数请求专用字段
appealRequest?: {
creatorName: string
@ -62,18 +91,48 @@ const mockMessages: Message[] = [
},
{
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: '10分钟前',
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-003',
id: 'msg-005',
type: 'appeal_quota_request',
title: '申诉次数申请',
content: '达人「美妆达人小王」申请增加「双11护肤品种草」的申诉次数',
@ -91,75 +150,165 @@ const mockMessages: Message[] = [
},
},
{
id: 'msg-004',
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: '「新品口红试色」视频已通过品牌方终审。',
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-005',
id: 'msg-008',
type: 'review_rejected',
title: '品牌终审驳回',
content: '「健身器材开箱」视频被品牌方驳回,原因:违禁词使用。',
content: '【BB运动饮料】达人「健身教练王」的视频「健身器材开箱」被品牌方驳回原因违禁词使用请通知达人修改',
time: '2小时前',
read: false,
icon: XCircle,
iconColor: 'text-accent-coral',
bgColor: 'bg-accent-coral/20',
platform: 'bilibili',
taskId: 'task-010',
},
{
id: 'msg-006',
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。',
content: '您被邀请参与「XX品牌新品推广」项目请配置 Brief',
time: '昨天',
read: true,
icon: Users,
icon: Building2,
iconColor: 'text-purple-400',
bgColor: 'bg-purple-500/20',
platform: 'douyin',
projectId: 'proj-001',
hasAction: true,
actionType: 'config',
},
{
id: 'msg-007',
id: 'msg-013',
type: 'warning',
title: '风险预警',
content: '达人「美妆Lisa」连续2次提交被驳回建议关注。',
content: '达人「美妆Lisa」连续2次提交被驳回建议关注并提供指导',
time: '昨天',
read: true,
icon: AlertTriangle,
iconColor: 'text-orange-400',
bgColor: 'bg-orange-500/20',
platform: 'xiaohongshu',
creatorName: '美妆Lisa',
},
{
id: 'msg-008',
id: 'msg-015',
type: 'task_submitted',
title: '新视频提交',
content: '达人「健身教练王」提交了「健身器材使用教程」视频,请及时审核。',
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(mockMessages)
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
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 filteredMessages = filter === 'all' ? messages : messages.filter(m => !m.read)
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 = (id: string) => {
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
@ -186,6 +335,77 @@ export default function AgencyMessagesPage() {
}))
}
// 处理消息点击
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">
{/* 页面标题 */}
@ -209,20 +429,32 @@ export default function AgencyMessagesPage() {
<button
type="button"
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
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={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
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})
({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>
@ -237,26 +469,28 @@ export default function AgencyMessagesPage() {
return (
<Card
key={message.id}
className={`transition-all overflow-hidden ${
!isAppealRequest ? 'cursor-pointer hover:border-accent-indigo/50' : ''
} ${!message.read ? 'border-l-4 border-l-accent-indigo' : ''}`}
onClick={() => !isAppealRequest && markAsRead(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={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<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={`text-xs font-medium ${platform.textColor}`}>{platform.name}</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={`w-10 h-10 rounded-lg ${message.bgColor} flex items-center justify-center flex-shrink-0`}>
<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={`font-medium ${!message.read ? 'text-text-primary' : 'text-text-secondary'}`}>
<h3 className={cn('font-medium', !message.read ? 'text-text-primary' : 'text-text-secondary')}>
{message.title}
</h3>
{!message.read && (
@ -275,10 +509,24 @@ export default function AgencyMessagesPage() {
)}
</div>
<p className="text-sm text-text-secondary mt-1">{message.content}</p>
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
<Clock size={12} />
{message.time}
</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' && (
@ -308,11 +556,6 @@ export default function AgencyMessagesPage() {
</div>
)}
</div>
{!isAppealRequest && (
<Button variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
<MoreVertical size={16} />
</Button>
)}
</div>
</CardContent>
</Card>

View File

@ -16,8 +16,10 @@ import {
ChevronRight,
Download,
Eye,
File
File,
MessageSquareWarning
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟脚本待审列表
@ -34,6 +36,7 @@ const mockScriptTasks = [
riskLevel: 'low' as const,
submittedAt: '2026-02-06 14:30',
hasHighRisk: false,
isAppeal: false, // 是否为申诉
},
{
id: 'script-002',
@ -47,6 +50,8 @@ const mockScriptTasks = [
riskLevel: 'medium' as const,
submittedAt: '2026-02-06 12:15',
hasHighRisk: true,
isAppeal: true, // 申诉重审
appealReason: '已修改违规用词,请求重新审核',
},
{
id: 'script-003',
@ -60,6 +65,7 @@ const mockScriptTasks = [
riskLevel: 'low' as const,
submittedAt: '2026-02-06 10:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'script-004',
@ -73,6 +79,8 @@ const mockScriptTasks = [
riskLevel: 'high' as const,
submittedAt: '2026-02-06 09:00',
hasHighRisk: true,
isAppeal: true,
appealReason: '对驳回原因有异议,内容符合要求',
},
]
@ -91,6 +99,7 @@ const mockVideoTasks = [
duration: '02:15',
submittedAt: '2026-02-06 15:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'video-002',
@ -105,6 +114,8 @@ const mockVideoTasks = [
duration: '03:42',
submittedAt: '2026-02-06 13:45',
hasHighRisk: true,
isAppeal: true,
appealReason: '已按要求重新剪辑,删除了争议片段',
},
{
id: 'video-003',
@ -119,6 +130,7 @@ const mockVideoTasks = [
duration: '04:20',
submittedAt: '2026-02-06 11:30',
hasHighRisk: true,
isAppeal: false,
},
{
id: 'video-004',
@ -133,6 +145,7 @@ const mockVideoTasks = [
duration: '01:45',
submittedAt: '2026-02-06 10:15',
hasHighRisk: false,
isAppeal: false,
},
]
@ -152,13 +165,18 @@ function ScoreTag({ score }: { score: number }) {
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function ScriptTaskCard({ task }: { task: ScriptTask }) {
function ScriptTaskCard({ task, onPreview }: { task: ScriptTask; onPreview: (task: ScriptTask) => void }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: 实现实际下载逻辑
alert(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
@ -168,6 +186,13 @@ function ScriptTaskCard({ task }: { task: ScriptTask }) {
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
@ -180,53 +205,74 @@ function ScriptTaskCard({ task }: { task: ScriptTask }) {
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize}</p>
</div>
<button
type="button"
onClick={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览文件"
>
<Eye size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
function VideoTaskCard({ task }: { task: VideoTask }) {
function VideoTaskCard({ task, onPreview }: { task: VideoTask; onPreview: (task: VideoTask) => void }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: 实现实际下载逻辑
alert(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
@ -236,6 +282,13 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
@ -248,41 +301,57 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize} · {task.duration}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize} · {task.duration}</p>
</div>
<button
type="button"
onClick={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览视频"
>
<Eye size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
@ -291,6 +360,8 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
export default function AgencyReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewScript, setPreviewScript] = useState<ScriptTask | null>(null)
const [previewVideo, setPreviewVideo] = useState<VideoTask | null>(null)
const filteredScripts = mockScriptTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -302,6 +373,10 @@ export default function AgencyReviewListPage() {
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 计算申诉数量
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
@ -318,6 +393,12 @@ export default function AgencyReviewListPage() {
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{mockVideoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</div>
</div>
@ -381,7 +462,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<ScriptTaskCard key={task.id} task={task} />
<ScriptTaskCard key={task.id} task={task} onPreview={setPreviewScript} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -408,7 +489,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<VideoTaskCard key={task.id} task={task} />
<VideoTaskCard key={task.id} task={task} onPreview={setPreviewVideo} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -420,6 +501,94 @@ export default function AgencyReviewListPage() {
</Card>
)}
</div>
{/* 脚本预览弹窗 */}
<Modal
isOpen={!!previewScript}
onClose={() => setPreviewScript(null)}
title={previewScript?.fileName || '脚本预览'}
size="lg"
>
<div className="space-y-4">
{previewScript?.isAppeal && previewScript?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewScript.appealReason}</p>
</div>
)}
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewScript?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewScript?.fileSize}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewScript(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewScript?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
{/* 视频预览弹窗 */}
<Modal
isOpen={!!previewVideo}
onClose={() => setPreviewVideo(null)}
title={previewVideo?.fileName || '视频预览'}
size="lg"
>
<div className="space-y-4">
{previewVideo?.isAppeal && previewVideo?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewVideo.appealReason}</p>
</div>
)}
<div className="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewVideo?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.fileSize}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.duration}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewVideo(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewVideo?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -16,8 +16,11 @@ import {
User,
Clock,
Eye,
Shield
Shield,
Download,
MessageSquareWarning
} from 'lucide-react'
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟脚本任务数据
const mockScriptTask = {
@ -28,6 +31,19 @@ const mockScriptTask = {
submittedAt: '2026-02-06 14:30',
aiScore: 88,
status: 'agent_reviewing',
// 文件信息
file: {
id: 'file-001',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileUrl: '/demo/scripts/script-001.docx', // 实际开发时替换为真实URL
uploadedAt: '2026-02-06 14:30',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
// 脚本内容AI解析后的结构化内容用于展示
scriptContent: {
opening: '大家好!今天给大家分享一款超级好用的夏日护肤神器~',
productIntro: '这款XX品牌的防晒霜SPF50+PA++++,真的是夏天出门必备!质地轻薄不油腻,涂上去清清爽爽的。',
@ -80,7 +96,8 @@ export default function AgencyScriptReviewPage() {
const [showForcePassModal, setShowForcePassModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [forcePassReason, setForcePassReason] = useState('')
const [viewMode, setViewMode] = useState<'simple' | 'preview'>('preview')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
const [showFilePreview, setShowFilePreview] = useState(false)
const task = mockScriptTask
@ -118,7 +135,15 @@ export default function AgencyScriptReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@ -130,32 +155,63 @@ export default function AgencyScriptReviewPage() {
</span>
</div>
</div>
<button
type="button"
onClick={() => setViewMode(viewMode === 'simple' ? 'preview' : 'simple')}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated rounded-lg"
>
<Eye size={16} />
{viewMode === 'simple' ? '展开预览' : '简洁模式'}
</button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setViewMode('file')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'file' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('parsed')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'parsed' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
AI解析
</button>
</div>
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:脚本内容 */}
<div className="lg:col-span-2 space-y-4">
{viewMode === 'simple' ? (
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
{viewMode === 'file' ? (
<Card>
<CardContent className="py-8 text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-primary font-medium">{task.title}</p>
<p className="text-sm text-text-secondary mt-2">"展开预览"</p>
<Button variant="secondary" className="mt-4" onClick={() => setViewMode('preview')}>
<Eye size={16} />
</Button>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<FilePreview file={task.file} />
</CardContent>
</Card>
) : (
@ -163,7 +219,8 @@ export default function AgencyScriptReviewPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
AI
<span className="text-xs font-normal text-text-tertiary ml-2">AI </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@ -353,6 +410,13 @@ export default function AgencyScriptReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}

View File

@ -17,8 +17,12 @@ import {
User,
Clock,
CheckCircle,
XCircle
XCircle,
Download,
ExternalLink,
MessageSquareWarning
} from 'lucide-react'
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟视频任务数据
const mockVideoTask = {
@ -30,6 +34,20 @@ const mockVideoTask = {
duration: 135,
aiScore: 85,
status: 'agent_reviewing',
// 文件信息
file: {
id: 'file-video-001',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
fileType: 'video/mp4',
fileUrl: '/demo/videos/video-001.mp4', // 实际开发时替换为真实URL
uploadedAt: '2026-02-06 15:00',
duration: '02:15',
thumbnail: '/demo/videos/video-001-thumb.jpg',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
hardViolations: [
{
id: 'v1',
@ -105,6 +123,8 @@ export default function AgencyVideoReviewPage() {
const [forcePassReason, setForcePassReason] = useState('')
const [saveAsException, setSaveAsException] = useState(false)
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const [showFilePreview, setShowFilePreview] = useState(false)
const [videoError, setVideoError] = useState(false)
const task = mockVideoTask
@ -149,7 +169,15 @@ export default function AgencyVideoReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@ -163,22 +191,61 @@ export default function AgencyVideoReviewPage() {
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧:视频播放器 (3/5) */}
<div className="lg:col-span-3 space-y-4">
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
<Card>
<CardContent className="p-0">
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
<button
type="button"
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
</button>
{/* 真实视频播放器 */}
<div className="aspect-video bg-gray-900 rounded-t-lg overflow-hidden relative">
{videoError ? (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Play size={48} className="mx-auto text-white/50 mb-3" />
<p className="text-white/70 mb-3"></p>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(task.file.fileUrl, '_blank')}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
) : (
<video
className="w-full h-full"
controls
poster={task.file.thumbnail}
onError={() => setVideoError(true)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={task.file.fileUrl} type={task.file.fileType} />
</video>
)}
</div>
{/* 智能进度条 */}
<div className="p-4 border-t border-border-subtle">
@ -416,6 +483,13 @@ export default function AgencyVideoReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}

View File

@ -0,0 +1,464 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
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' // 系统通知
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' },
}
// 模拟消息数据
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(mockMessages)
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
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 = (id: string) => {
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
}
const markAllAsRead = () => {
setMessages(prev => prev.map(m => ({ ...m, read: true })))
}
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]
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>
)
}

View File

@ -14,8 +14,13 @@ import {
User,
Building,
ChevronRight,
AlertTriangle
AlertTriangle,
Download,
Eye,
File,
MessageSquareWarning
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟脚本待审列表
@ -23,6 +28,8 @@ const mockScriptTasks = [
{
id: 'script-001',
title: '夏日护肤推广脚本',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
creatorName: '小美护肤',
agencyName: '星耀传媒',
projectName: 'XX品牌618推广',
@ -31,10 +38,13 @@ const mockScriptTasks = [
submittedAt: '2026-02-06 14:30',
hasHighRisk: false,
agencyApproved: true,
isAppeal: false,
},
{
id: 'script-002',
title: '新品口红试色脚本',
fileName: '口红试色_脚本v1.docx',
fileSize: '312 KB',
creatorName: '美妆Lisa',
agencyName: '创意无限',
projectName: 'XX品牌618推广',
@ -43,6 +53,8 @@ const mockScriptTasks = [
submittedAt: '2026-02-06 12:15',
hasHighRisk: true,
agencyApproved: true,
isAppeal: true,
appealReason: '已修改违规用词,请求品牌方重新审核',
},
]
@ -51,6 +63,8 @@ const mockVideoTasks = [
{
id: 'video-001',
title: '夏日护肤推广',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
creatorName: '小美护肤',
agencyName: '星耀传媒',
projectName: 'XX品牌618推广',
@ -60,10 +74,13 @@ const mockVideoTasks = [
submittedAt: '2026-02-06 15:00',
hasHighRisk: false,
agencyApproved: true,
isAppeal: false,
},
{
id: 'video-002',
title: '新品口红试色',
fileName: '口红试色_终版.mp4',
fileSize: '256 MB',
creatorName: '美妆Lisa',
agencyName: '创意无限',
projectName: 'XX品牌618推广',
@ -73,10 +90,14 @@ const mockVideoTasks = [
submittedAt: '2026-02-06 13:45',
hasHighRisk: true,
agencyApproved: true,
isAppeal: true,
appealReason: '已按要求重新剪辑,删除了争议片段,请求终审',
},
{
id: 'video-003',
title: '健身器材开箱',
fileName: '健身器材_开箱v3.mp4',
fileSize: '198 MB',
creatorName: '健身教练王',
agencyName: '美妆达人MCN',
projectName: 'XX运动品牌',
@ -86,6 +107,7 @@ const mockVideoTasks = [
submittedAt: '2026-02-06 11:30',
hasHighRisk: false,
agencyApproved: true,
isAppeal: false,
},
]
@ -95,10 +117,33 @@ function ScoreTag({ score }: { score: number }) {
return <ErrorTag>{score}</ErrorTag>
}
function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof mockVideoTasks[0]; type: 'script' | 'video' }) {
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function TaskCard({
task,
type,
onPreview
}: {
task: ScriptTask | VideoTask
type: 'script' | 'video'
onPreview: (task: ScriptTask | VideoTask, type: 'script' | 'video') => void
}) {
const href = type === 'script' ? `/brand/review/script/${task.id}` : `/brand/review/video/${task.id}`
const platform = getPlatformInfo(task.platform)
const handlePreview = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
onPreview(task, type)
}
const handleDownload = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
alert(`下载文件: ${task.fileName}`)
}
return (
<Link href={href}>
<div className="rounded-lg border border-border-subtle hover:border-accent-indigo/50 hover:bg-accent-indigo/5 transition-all cursor-pointer overflow-hidden">
@ -107,6 +152,13 @@ function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof moc
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
@ -134,6 +186,49 @@ function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof moc
</div>
<ScoreTag score={task.aiScore} />
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${type === 'script' ? 'bg-accent-indigo/15' : 'bg-purple-500/15'}`}>
{type === 'script' ? (
<File size={20} className="text-accent-indigo" />
) : (
<Video size={20} className="text-purple-400" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">
{task.fileSize}
{'duration' in task && ` · ${task.duration}`}
</p>
</div>
<button
type="button"
onClick={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title={type === 'script' ? '预览脚本' : '预览视频'}
>
<Eye size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
<div className="flex items-center justify-between text-xs text-text-tertiary">
<span>{task.projectName}</span>
<span className="flex items-center gap-1">
@ -141,11 +236,6 @@ function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof moc
{task.submittedAt}
</span>
</div>
{'duration' in task && (
<div className="mt-2 text-xs text-text-tertiary">
: {task.duration}
</div>
)}
</div>
</div>
</Link>
@ -155,6 +245,7 @@ function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof moc
export default function BrandReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewTask, setPreviewTask] = useState<{ task: ScriptTask | VideoTask; type: 'script' | 'video' } | null>(null)
const filteredScripts = mockScriptTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -166,6 +257,14 @@ export default function BrandReviewListPage() {
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 计算申诉数量
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
const handlePreview = (task: ScriptTask | VideoTask, type: 'script' | 'video') => {
setPreviewTask({ task, type })
}
return (
<div className="space-y-6">
{/* 页面标题 */}
@ -182,6 +281,12 @@ export default function BrandReviewListPage() {
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{mockVideoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</div>
</div>
@ -245,7 +350,7 @@ export default function BrandReviewListPage() {
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<TaskCard key={task.id} task={task} type="script" />
<TaskCard key={task.id} task={task} type="script" onPreview={handlePreview} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -272,7 +377,7 @@ export default function BrandReviewListPage() {
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<TaskCard key={task.id} task={task} type="video" />
<TaskCard key={task.id} task={task} type="video" onPreview={handlePreview} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -284,6 +389,70 @@ export default function BrandReviewListPage() {
</Card>
)}
</div>
{/* 预览弹窗 */}
<Modal
isOpen={!!previewTask}
onClose={() => setPreviewTask(null)}
title={previewTask?.task.fileName || '文件预览'}
size="lg"
>
<div className="space-y-4">
{/* 申诉理由 */}
{previewTask?.task.isAppeal && previewTask?.task.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewTask.task.appealReason}</p>
</div>
)}
{/* 预览区域 */}
{previewTask?.type === 'video' ? (
<div className="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
) : (
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
)}
{/* 文件信息和操作 */}
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewTask?.task.fileName}</span>
<span className="mx-2">·</span>
<span>{previewTask?.task.fileSize}</span>
{previewTask?.type === 'video' && 'duration' in (previewTask?.task || {}) && (
<>
<span className="mx-2">·</span>
<span>{(previewTask.task as VideoTask).duration}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewTask(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewTask?.task.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -19,8 +19,10 @@ import {
Eye,
Download,
Shield,
MessageSquare
MessageSquare,
MessageSquareWarning
} from 'lucide-react'
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟脚本任务数据
const mockScriptTask = {
@ -32,6 +34,18 @@ const mockScriptTask = {
submittedAt: '2026-02-06 14:30',
aiScore: 88,
status: 'brand_reviewing',
// 文件信息
file: {
id: 'file-001',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileUrl: '/demo/scripts/script-001.docx',
uploadedAt: '2026-02-06 14:30',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
scriptContent: {
opening: '大家好!今天给大家分享一款超级好用的夏日护肤神器~',
productIntro: '这款XX品牌的防晒霜SPF50+PA++++,真的是夏天出门必备!质地轻薄不油腻,涂上去清清爽爽的。',
@ -88,7 +102,8 @@ export default function BrandScriptReviewPage() {
const [showApproveModal, setShowApproveModal] = useState(false)
const [showRejectModal, setShowRejectModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [viewMode, setViewMode] = useState<'simple' | 'preview'>('preview')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file')
const [showFilePreview, setShowFilePreview] = useState(false)
const task = mockScriptTask
@ -116,7 +131,15 @@ export default function BrandScriptReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@ -133,33 +156,62 @@ export default function BrandScriptReviewPage() {
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewMode(viewMode === 'simple' ? 'preview' : 'simple')}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated rounded-lg"
>
<Eye size={16} />
{viewMode === 'simple' ? '展开预览' : '简洁模式'}
</button>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setViewMode('file')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'file' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('parsed')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'parsed' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
AI解析
</button>
</div>
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:脚本内容 */}
<div className="lg:col-span-2 space-y-4">
{viewMode === 'simple' ? (
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
{viewMode === 'file' ? (
<Card>
<CardContent className="py-8 text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-primary font-medium">{task.title}</p>
<p className="text-sm text-text-secondary mt-2">"展开预览"</p>
<Button variant="secondary" className="mt-4" onClick={() => setViewMode('preview')}>
<Eye size={16} />
</Button>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<FilePreview file={task.file} />
</CardContent>
</Card>
) : (
@ -167,7 +219,8 @@ export default function BrandScriptReviewPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
AI
<span className="text-xs font-normal text-text-tertiary ml-2">AI </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@ -358,6 +411,13 @@ export default function BrandScriptReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}

View File

@ -19,8 +19,11 @@ import {
Clock,
CheckCircle,
XCircle,
MessageSquare
MessageSquare,
ExternalLink,
MessageSquareWarning
} from 'lucide-react'
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟视频任务数据
const mockVideoTask = {
@ -33,6 +36,20 @@ const mockVideoTask = {
duration: 135, // 秒
aiScore: 85,
status: 'brand_reviewing',
// 文件信息
file: {
id: 'file-video-001',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
fileType: 'video/mp4',
fileUrl: '/demo/videos/video-001.mp4',
uploadedAt: '2026-02-06 15:00',
duration: '02:15',
thumbnail: '/demo/videos/video-001-thumb.jpg',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
agencyReview: {
reviewer: '张经理',
result: 'approved',
@ -111,6 +128,8 @@ export default function BrandVideoReviewPage() {
const [showRejectModal, setShowRejectModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const [showFilePreview, setShowFilePreview] = useState(false)
const [videoError, setVideoError] = useState(false)
const task = mockVideoTask
@ -145,7 +164,15 @@ export default function BrandVideoReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@ -163,22 +190,61 @@ export default function BrandVideoReviewPage() {
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧:视频播放器 (3/5) */}
<div className="lg:col-span-3 space-y-4">
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
<Card>
<CardContent className="p-0">
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
<button
type="button"
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
</button>
{/* 真实视频播放器 */}
<div className="aspect-video bg-gray-900 rounded-t-lg overflow-hidden relative">
{videoError ? (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Play size={48} className="mx-auto text-white/50 mb-3" />
<p className="text-white/70 mb-3"></p>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(task.file.fileUrl, '_blank')}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
) : (
<video
className="w-full h-full"
controls
poster={task.file.thumbnail}
onError={() => setVideoError(true)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={task.file.fileUrl} type={task.file.fileType} />
</video>
)}
</div>
{/* 智能进度条 */}
<div className="p-4 border-t border-border-subtle">
@ -408,6 +474,13 @@ export default function BrandVideoReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}

View File

@ -13,7 +13,13 @@ import {
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'
@ -29,9 +35,16 @@ type MessageType =
| 'brand_pass' // 品牌方审核通过
| 'brand_reject' // 品牌方审核驳回
| 'video_ai' // 视频AI审核完成
| 'appeal' // 申诉结果
| 'video_agency_reject' // 视频代理商驳回
| 'video_brand_reject' // 视频品牌方驳回
| '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
@ -42,6 +55,8 @@ type Message = {
read: boolean
taskId?: string
hasActions?: boolean // 是否有操作按钮(邀请类型)
agencyName?: string // 代理商名称(新任务类型)
taskName?: string // 任务名称(新任务类型)
}
// 消息配置
@ -61,8 +76,15 @@ const messageConfig: Record<MessageType, {
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条消息数据
@ -80,10 +102,12 @@ const mockMessages: Message[] = [
id: 'msg-002',
type: 'new_task',
title: '新任务分配',
content: '您有一个新任务【XX品牌618推广】请在3天内提交脚本',
content: '您被「星辰传媒」安排了新任务【XX品牌618推广】请先查看任务要求后再提交脚本',
time: '10分钟前',
read: false,
taskId: 'task-001',
agencyName: '星辰传媒',
taskName: 'XX品牌618推广',
},
{
id: 'msg-003',
@ -159,11 +183,39 @@ const mockMessages: Message[] = [
},
{
id: 'msg-011',
type: 'appeal',
title: '申诉结果通知',
content: '您的申诉已通过AI已学习您的反馈感谢您帮助我们改进系统',
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',
@ -183,6 +235,32 @@ const mockMessages: Message[] = [
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,
},
]
// 消息卡片组件
@ -190,12 +268,14 @@ function MessageCard({
message,
onRead,
onNavigate,
onViewBrief,
onAcceptInvite,
onIgnoreInvite,
}: {
message: Message
onRead: () => void
onNavigate: () => void
onViewBrief?: () => void
onAcceptInvite?: () => void
onIgnoreInvite?: () => void
}) {
@ -205,14 +285,18 @@ function MessageCard({
return (
<div
className={cn(
'rounded-xl p-4 flex gap-4 cursor-pointer transition-colors',
'rounded-xl p-4 flex gap-4 transition-colors',
message.read
? 'bg-transparent border border-bg-elevated'
: 'bg-bg-elevated'
: 'bg-bg-elevated',
// 新任务和邀请类型不整体点击跳转
message.type !== 'new_task' && message.type !== 'invite' && message.taskId ? 'cursor-pointer' : ''
)}
onClick={() => {
onRead()
if (message.taskId) onNavigate()
if (message.type !== 'new_task' && message.type !== 'invite') {
onRead()
if (message.taskId) onNavigate()
}
}}
>
{/* 图标 */}
@ -231,6 +315,34 @@ function MessageCard({
{/* 描述 */}
<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">
@ -386,9 +498,17 @@ export default function CreatorMessagesPage() {
// 邀请消息不跳转,在卡片内有操作按钮
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}`)
@ -425,6 +545,18 @@ export default function CreatorMessagesPage() {
// 申诉结果 -> 跳转到申诉中心
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}`)
@ -501,6 +633,9 @@ export default function CreatorMessagesPage() {
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)}
/>

View File

@ -0,0 +1,317 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import {
ArrowLeft,
FileText,
Download,
Eye,
Target,
Ban,
File,
Building2,
Calendar,
Clock,
ChevronRight
} from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
// 代理商Brief文档类型
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 模拟任务数据
const mockTaskInfo = {
id: 'task-001',
taskName: 'XX品牌618推广',
agencyName: '星辰传媒',
brandName: 'XX护肤品牌',
deadline: '2026-06-18',
createdAt: '2026-02-08',
}
// 模拟代理商Brief数据
const mockAgencyBrief = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
{ id: 'af4', name: '产品素材包.zip', size: '15.6MB', uploadedAt: '2026-02-02', description: '产品图片、视频素材等' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
{ id: 'bw5', word: '100%', reason: '虚假宣传' },
],
contentRequirements: [
'视频时长60-90秒',
'需展示产品质地和使用效果',
'需在户外或阳光下拍摄',
'需提及产品核心卖点',
],
}
export default function TaskBriefPage() {
const router = useRouter()
const params = useParams()
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
alert(`下载文件: ${file.name}`)
}
const handleDownloadAll = () => {
alert('下载全部文件')
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
return (
<ResponsiveLayout role="creator">
<div className="flex flex-col gap-6 h-full">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3 mb-1">
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<h1 className="text-xl lg:text-[28px] font-bold text-text-primary">{mockTaskInfo.taskName}</h1>
<p className="text-sm lg:text-[15px] text-text-secondary">Brief文档</p>
</div>
<Button onClick={() => router.push(`/creator/task/${params.id}`)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* 任务基本信息 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<h3 className="text-base font-semibold text-text-primary mb-4"></h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.agencyName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
<Building2 className="w-5 h-5 text-accent-indigo" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.brandName}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-green/15 flex items-center justify-center">
<Calendar className="w-5 h-5 text-accent-green" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.createdAt}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-coral/15 flex items-center justify-center">
<Clock className="w-5 h-5 text-accent-coral" />
</div>
<div>
<p className="text-xs text-text-tertiary"></p>
<p className="text-sm font-medium text-text-primary">{mockTaskInfo.deadline}</p>
</div>
</div>
</div>
</div>
{/* 主要内容区域 - 可滚动 */}
<div className="flex-1 overflow-y-auto space-y-6">
{/* Brief文档列表 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<h3 className="text-base font-semibold text-text-primary">Brief </h3>
<span className="text-sm text-text-tertiary">({mockAgencyBrief.files.length})</span>
</div>
<Button variant="secondary" size="sm" onClick={handleDownloadAll}>
<Download className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{mockAgencyBrief.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-4 bg-bg-elevated rounded-xl hover:bg-bg-page transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-11 h-11 rounded-xl bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-0.5 truncate">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2.5 hover:bg-bg-card rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
{/* 内容要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-accent-amber" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<ul className="space-y-2">
{mockAgencyBrief.contentRequirements.map((req, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber mt-2 flex-shrink-0" />
{req}
</li>
))}
</ul>
</div>
{/* 卖点要求 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-accent-green" />
<h3 className="text-base font-semibold text-text-primary"></h3>
</div>
<div className="space-y-3">
{requiredPoints.length > 0 && (
<div className="p-4 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-accent-coral/20 text-accent-coral rounded-lg font-medium">
{sp.content}
</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-4 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-semibold mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-3 py-1.5 text-sm bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* 违禁词 */}
<div className="bg-bg-card rounded-2xl p-5 card-shadow">
<div className="flex items-center gap-2 mb-4">
<Ban className="w-5 h-5 text-accent-coral" />
<h3 className="text-base font-semibold text-text-primary">使</h3>
</div>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span
key={bw.id}
className="px-3 py-1.5 text-sm bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30"
>
{bw.word}<span className="text-xs opacity-75 ml-1">{bw.reason}</span>
</span>
))}
</div>
</div>
{/* 底部操作按钮 */}
<div className="flex justify-center py-4">
<Button size="lg" onClick={() => router.push(`/creator/task/${params.id}`)}>
<ChevronRight className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* 文件预览弹窗 */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Modal>
</ResponsiveLayout>
)
}

View File

@ -6,8 +6,11 @@ import {
Upload, Check, X, Folder, Bell, MessageCircle,
XCircle, CheckCircle, Loader2, Scan, ArrowLeft,
Bot, Users, Building2, Clock, FileText, Video,
ChevronRight, AlertTriangle
ChevronRight, AlertTriangle, Download, Eye, Target, Ban,
ChevronDown, ChevronUp, File
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
import { cn } from '@/lib/utils'
@ -50,6 +53,36 @@ type TaskData = {
scriptContent?: string
}
// 代理商Brief文档达人可查看
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
const mockAgencyBrief = {
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
] as AgencyBriefFile[],
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
}
// 所有15个任务的详细数据
const allTasksData: Record<string, TaskData> = {
'task-001': {
@ -355,6 +388,164 @@ function ReviewProgressBar({ task }: { task: TaskData }) {
)
}
// Brief文档查看组件
function AgencyBriefSection() {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
alert(`下载文件: ${file.name}`)
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
return (
<>
<div className="bg-bg-card rounded-2xl card-shadow border border-accent-indigo/30">
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-accent-indigo" />
<span className="text-base font-semibold text-text-primary">Brief </span>
</div>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 hover:bg-bg-elevated rounded-lg transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-text-tertiary" />
) : (
<ChevronDown className="w-5 h-5 text-text-tertiary" />
)}
</button>
</div>
{isExpanded && (
<div className="p-4 space-y-4">
{/* Brief文档列表 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-accent-indigo" />
</h4>
<div className="space-y-2">
{mockAgencyBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-bg-elevated rounded-xl">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => setPreviewFile(file)}
className="p-2 hover:bg-bg-page rounded-lg transition-colors"
>
<Eye className="w-4 h-4 text-text-secondary" />
</button>
<button
type="button"
onClick={() => handleDownload(file)}
className="p-2 hover:bg-bg-page rounded-lg transition-colors"
>
<Download className="w-4 h-4 text-text-secondary" />
</button>
</div>
</div>
))}
</div>
</div>
{/* 卖点要求 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-accent-green" />
</h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-xl">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">
{sp.content}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* 违禁词 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Ban className="w-4 h-4 text-accent-coral" />
使
</h4>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span key={bw.id} className="px-2 py-1 text-xs bg-accent-coral/15 text-accent-coral rounded-lg border border-accent-coral/30">
{bw.word}
</span>
))}
</div>
</div>
</div>
)}
</div>
{/* 文件预览弹窗 */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Modal>
</>
)
}
// 上传界面
function UploadView({ task }: { task: TaskData }) {
const [isDragging, setIsDragging] = useState(false)
@ -362,6 +553,9 @@ function UploadView({ task }: { task: TaskData }) {
return (
<div className="flex flex-col gap-6 h-full">
{/* Brief文档区域 - 仅脚本阶段显示 */}
{isScript && <AgencyBriefSection />}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">

View File

@ -11,6 +11,17 @@ export { Input, SearchInput, PasswordInput, type InputProps } from './ui/Input';
export { Select, type SelectProps, type SelectOption } from './ui/Select';
export { ProgressBar, CircularProgress, type ProgressBarProps, type CircularProgressProps } from './ui/ProgressBar';
export { Modal, ConfirmModal, type ModalProps, type ConfirmModalProps } from './ui/Modal';
export {
FilePreview,
FileInfoCard,
FilePreviewModal,
VideoPlayer,
ImageViewer,
PDFViewer,
DocumentPlaceholder,
getFileCategory,
type FileInfo
} from './ui/FilePreview';
// 导航组件
export { BottomNav } from './navigation/BottomNav';

View File

@ -52,6 +52,7 @@ const brandNavItems: NavItem[] = [
{ icon: FolderKanban, label: '项目看板', href: '/brand' },
{ icon: PlusCircle, label: '创建项目', href: '/brand/projects/create' },
{ icon: ClipboardCheck, label: '终审台', href: '/brand/review' },
{ icon: Bell, label: '消息中心', href: '/brand/messages' },
{ icon: Users, label: '代理商管理', href: '/brand/agencies' },
{ icon: FileText, label: '规则配置', href: '/brand/rules' },
{ icon: Bot, label: 'AI 配置', href: '/brand/ai-config' },

View File

@ -0,0 +1,403 @@
'use client'
import { useState } from 'react'
import {
FileText,
Video,
Image as ImageIcon,
File,
Download,
ExternalLink,
Play,
Pause,
Maximize2,
X,
AlertCircle
} from 'lucide-react'
import { Button } from './Button'
import { Modal } from './Modal'
// 文件信息类型
export interface FileInfo {
id: string
fileName: string
fileSize: string
fileType: string // MIME type: "video/mp4", "application/pdf", etc.
fileUrl: string
uploadedAt?: string
duration?: string // 视频时长 "02:15"
thumbnail?: string // 视频缩略图
}
// 根据文件名或MIME类型判断文件类别
export function getFileCategory(file: FileInfo): 'video' | 'image' | 'pdf' | 'document' | 'spreadsheet' | 'other' {
const fileName = file.fileName.toLowerCase()
const mimeType = file.fileType.toLowerCase()
// 视频
if (mimeType.startsWith('video/') || /\.(mp4|mov|webm|avi|mkv)$/.test(fileName)) {
return 'video'
}
// 图片
if (mimeType.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|svg)$/.test(fileName)) {
return 'image'
}
// PDF
if (mimeType === 'application/pdf' || fileName.endsWith('.pdf')) {
return 'pdf'
}
// Word 文档
if (
mimeType.includes('word') ||
mimeType.includes('document') ||
/\.(doc|docx|txt|rtf)$/.test(fileName)
) {
return 'document'
}
// Excel 表格
if (
mimeType.includes('sheet') ||
mimeType.includes('excel') ||
/\.(xls|xlsx|csv)$/.test(fileName)
) {
return 'spreadsheet'
}
return 'other'
}
// 获取文件图标
function getFileIcon(category: ReturnType<typeof getFileCategory>) {
switch (category) {
case 'video':
return <Video className="w-6 h-6 text-purple-400" />
case 'image':
return <ImageIcon className="w-6 h-6 text-accent-green" />
case 'pdf':
return <FileText className="w-6 h-6 text-red-400" />
case 'document':
return <FileText className="w-6 h-6 text-accent-indigo" />
case 'spreadsheet':
return <FileText className="w-6 h-6 text-green-500" />
default:
return <File className="w-6 h-6 text-text-secondary" />
}
}
// 文件信息卡片组件
export function FileInfoCard({
file,
onPreview,
onDownload,
showPreviewButton = true
}: {
file: FileInfo
onPreview?: () => void
onDownload?: () => void
showPreviewButton?: boolean
}) {
const category = getFileCategory(file)
const handleDownload = () => {
if (onDownload) {
onDownload()
} else {
// 默认下载行为
const link = document.createElement('a')
link.href = file.fileUrl
link.download = file.fileName
link.click()
}
}
const handleOpenInNewTab = () => {
window.open(file.fileUrl, '_blank')
}
return (
<div className="flex items-center gap-3 p-4 rounded-xl bg-bg-elevated">
<div className="w-12 h-12 rounded-xl bg-bg-page flex items-center justify-center flex-shrink-0">
{getFileIcon(category)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.fileName}</p>
<p className="text-xs text-text-tertiary">
{file.fileSize}
{file.duration && ` · ${file.duration}`}
{file.uploadedAt && ` · ${file.uploadedAt}`}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{showPreviewButton && onPreview && (
<button
type="button"
onClick={onPreview}
className="p-2.5 rounded-lg hover:bg-bg-page transition-colors"
title="预览"
>
<Maximize2 size={18} className="text-text-secondary" />
</button>
)}
<button
type="button"
onClick={handleOpenInNewTab}
className="p-2.5 rounded-lg hover:bg-bg-page transition-colors"
title="在新标签页打开"
>
<ExternalLink size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2.5 rounded-lg hover:bg-bg-page transition-colors"
title="下载"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
</div>
)
}
// 视频播放器组件
export function VideoPlayer({
file,
className = '',
showControls = true
}: {
file: FileInfo
className?: string
showControls?: boolean
}) {
const [isPlaying, setIsPlaying] = useState(false)
const [error, setError] = useState(false)
if (error) {
return (
<div className={`aspect-video bg-bg-elevated rounded-xl flex items-center justify-center ${className}`}>
<div className="text-center">
<AlertCircle className="w-12 h-12 mx-auto text-accent-coral mb-3" />
<p className="text-text-secondary"></p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => window.open(file.fileUrl, '_blank')}>
<ExternalLink size={14} />
</Button>
</div>
</div>
)
}
return (
<div className={`relative rounded-xl overflow-hidden bg-black ${className}`}>
<video
className="w-full h-full"
controls={showControls}
poster={file.thumbnail}
onError={() => setError(true)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={file.fileUrl} type={file.fileType} />
</video>
</div>
)
}
// 图片查看器组件
export function ImageViewer({
file,
className = ''
}: {
file: FileInfo
className?: string
}) {
const [error, setError] = useState(false)
if (error) {
return (
<div className={`aspect-video bg-bg-elevated rounded-xl flex items-center justify-center ${className}`}>
<div className="text-center">
<AlertCircle className="w-12 h-12 mx-auto text-accent-coral mb-3" />
<p className="text-text-secondary"></p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => window.open(file.fileUrl, '_blank')}>
<ExternalLink size={14} />
</Button>
</div>
</div>
)
}
return (
<div className={`bg-bg-elevated rounded-xl overflow-hidden flex items-center justify-center ${className}`}>
<img
src={file.fileUrl}
alt={file.fileName}
className="max-w-full max-h-full object-contain"
onError={() => setError(true)}
/>
</div>
)
}
// PDF 查看器组件
export function PDFViewer({
file,
className = ''
}: {
file: FileInfo
className?: string
}) {
const [error, setError] = useState(false)
if (error) {
return (
<div className={`aspect-[4/3] bg-bg-elevated rounded-xl flex items-center justify-center ${className}`}>
<div className="text-center">
<AlertCircle className="w-12 h-12 mx-auto text-accent-coral mb-3" />
<p className="text-text-secondary">PDF </p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => window.open(file.fileUrl, '_blank')}>
<ExternalLink size={14} />
</Button>
</div>
</div>
)
}
return (
<div className={`rounded-xl overflow-hidden ${className}`}>
<iframe
src={file.fileUrl}
className="w-full h-full min-h-[500px] border-0"
title={file.fileName}
onError={() => setError(true)}
/>
</div>
)
}
// 文档预览占位组件Word/Excel 等不支持内嵌预览的格式)
export function DocumentPlaceholder({
file,
className = ''
}: {
file: FileInfo
className?: string
}) {
const category = getFileCategory(file)
return (
<div className={`aspect-[4/3] bg-bg-elevated rounded-xl flex items-center justify-center ${className}`}>
<div className="text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-bg-page flex items-center justify-center">
{getFileIcon(category)}
</div>
<p className="text-text-primary font-medium mb-1">{file.fileName}</p>
<p className="text-sm text-text-tertiary mb-4">{file.fileSize}</p>
<p className="text-sm text-text-secondary mb-4">
线
</p>
<div className="flex gap-2 justify-center">
<Button variant="secondary" onClick={() => window.open(file.fileUrl, '_blank')}>
<ExternalLink size={16} />
</Button>
<Button onClick={() => {
const link = document.createElement('a')
link.href = file.fileUrl
link.download = file.fileName
link.click()
}}>
<Download size={16} />
</Button>
</div>
</div>
</div>
)
}
// 统一的文件预览组件 - 根据文件类型自动选择预览方式
export function FilePreview({
file,
className = ''
}: {
file: FileInfo
className?: string
}) {
const category = getFileCategory(file)
switch (category) {
case 'video':
return <VideoPlayer file={file} className={className} />
case 'image':
return <ImageViewer file={file} className={className} />
case 'pdf':
return <PDFViewer file={file} className={className} />
default:
return <DocumentPlaceholder file={file} className={className} />
}
}
// 文件预览弹窗组件
export function FilePreviewModal({
file,
isOpen,
onClose
}: {
file: FileInfo | null
isOpen: boolean
onClose: () => void
}) {
if (!file) return null
const category = getFileCategory(file)
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={file.fileName}
size="xl"
>
<div className="space-y-4">
{/* 预览区域 */}
<div className="min-h-[400px]">
<FilePreview file={file} className="h-full" />
</div>
{/* 底部信息和操作 */}
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
<div className="text-sm text-text-secondary">
<span>{file.fileSize}</span>
{file.duration && (
<>
<span className="mx-2">·</span>
<span>{file.duration}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => window.open(file.fileUrl, '_blank')}>
<ExternalLink size={16} />
</Button>
<Button onClick={() => {
const link = document.createElement('a')
link.href = file.fileUrl
link.download = file.fileName
link.click()
}}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default FilePreview