feat: 完善审核台文件预览与消息通知系统
主要更新: - 新增 FilePreview 通用组件,支持视频/图片/PDF 内嵌预览 - 审核详情页添加文件信息卡片、预览/下载功能 - 审核列表和详情页添加申诉标识和申诉理由显示 - 完善三端消息通知系统(达人/代理商/品牌) - 新增达人 Brief 查看页面 - 新增品牌方消息中心页面 - 创建后端开发备忘文档 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bbc8a4f641
commit
a5a005db0c
94
backend/BACKEND_TODO.md
Normal file
94
backend/BACKEND_TODO.md
Normal 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(有效期限制)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
464
frontend/app/brand/messages/page.tsx
Normal file
464
frontend/app/brand/messages/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
317
frontend/app/creator/task/[id]/brief/page.tsx
Normal file
317
frontend/app/creator/task/[id]/brief/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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' },
|
||||
|
||||
403
frontend/components/ui/FilePreview.tsx
Normal file
403
frontend/components/ui/FilePreview.tsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user