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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-09 12:20:47 +08:00
co-authored by Claude Opus 4.5
parent bbc8a4f641
commit a5a005db0c
16 changed files with 3251 additions and 269 deletions
+581 -70
View File
@@ -4,30 +4,84 @@ import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import { Modal } from '@/components/ui/Modal'
import { SuccessTag } from '@/components/ui/Tag'
import {
ArrowLeft,
FileText,
Upload,
Download,
Eye,
Target,
Ban,
AlertTriangle,
Sparkles,
FileDown,
CheckCircle,
Clock,
Building2,
Info,
Plus,
X,
Save,
Sparkles,
Target,
Ban,
AlertTriangle
Upload,
Trash2,
File
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟 Brief 详情
const mockBrief = {
// 文件类型
type BriefFile = {
id: string
name: string
type: 'brief' | 'rule' | 'reference'
size: string
uploadedAt: string
}
// 模拟品牌方 Brief(只读)
const mockBrandBrief = {
id: 'brief-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
platform: 'douyin',
// 品牌方上传的文件列表
files: [
{ id: 'f1', name: 'XX品牌618推广Brief.pdf', type: 'brief' as const, size: '2.3MB', uploadedAt: '2026-02-01' },
{ id: 'f2', name: '产品卖点说明.docx', type: 'reference' as const, size: '1.2MB', uploadedAt: '2026-02-01' },
{ id: 'f3', name: '品牌视觉指南.pdf', type: 'reference' as const, size: '5.8MB', uploadedAt: '2026-02-01' },
],
// 品牌方配置的规则(只读)
brandRules: {
restrictions: '不可提及竞品,不可使用绝对化用语',
competitors: ['安耐晒', '资生堂', '兰蔻'],
},
}
// 代理商上传的Brief文档(可编辑)
type AgencyFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
// 代理商自己的配置(可编辑)
const mockAgencyConfig = {
status: 'configured',
fileName: 'XX品牌618推广Brief.pdf',
uploadedAt: '2026-02-01',
configuredAt: '2026-02-02',
// 代理商上传的Brief文档(给达人看的)
agencyFiles: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
] as AgencyFile[],
// AI 解析出的内容
aiParsedContent: {
productName: 'XX品牌防晒霜',
targetAudience: '18-35岁女性',
contentRequirements: '需展示产品质地、使用效果,视频时长30-60秒',
},
// 代理商配置的卖点(可编辑)
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
@@ -35,40 +89,104 @@ const mockBrief = {
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
// 代理商配置的违禁词(可编辑)
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
aiParsedContent: {
productName: 'XX品牌防晒霜',
targetAudience: '18-35岁女性',
contentRequirements: '需展示产品质地、使用效果',
restrictions: '不可提及竞品,不可使用绝对化用语',
}
// 平台规则
const platformRules = {
douyin: {
name: '抖音',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
{ category: '医疗相关禁用', items: ['治疗', '药用', '医学', '临床', '处方'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用', '立竿见影'] },
],
},
xiaohongshu: {
name: '小红书',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极品', '绝对'] },
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
],
},
bilibili: {
name: 'B站',
rules: [
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致'] },
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用'] },
],
},
}
export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const [brief, setBrief] = useState(mockBrief)
// 品牌方 Brief(只读)
const [brandBrief] = useState(mockBrandBrief)
// 代理商配置(可编辑)
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
const [isAIParsing, setIsAIParsing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// 弹窗状态
const [showFilesModal, setShowFilesModal] = useState(false)
const [showAgencyFilesModal, setShowAgencyFilesModal] = useState(false)
const [previewFile, setPreviewFile] = useState<BriefFile | null>(null)
const [previewAgencyFile, setPreviewAgencyFile] = useState<AgencyFile | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const platform = getPlatformInfo(brandBrief.platform)
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
// 下载文件
const handleDownload = (file: BriefFile) => {
alert(`下载文件: ${file.name}`)
}
// 预览文件
const handlePreview = (file: BriefFile) => {
setPreviewFile(file)
}
// 导出平台规则文档
const handleExportRules = async () => {
setIsExporting(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsExporting(false)
alert('平台规则文档已导出!')
}
// AI 解析
const handleAIParse = async () => {
setIsAIParsing(true)
// 模拟 AI 解析
await new Promise(resolve => setTimeout(resolve, 2000))
setIsAIParsing(false)
alert('AI 解析完成!')
}
// 保存配置
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存!')
}
// 卖点操作
const addSellingPoint = () => {
if (!newSellingPoint.trim()) return
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }]
}))
@@ -76,14 +194,14 @@ export default function BriefConfigPage() {
}
const removeSellingPoint = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: prev.sellingPoints.filter(sp => sp.id !== id)
}))
}
const toggleRequired = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
sellingPoints: prev.sellingPoints.map(sp =>
sp.id === id ? { ...sp, required: !sp.required } : sp
@@ -91,9 +209,10 @@ export default function BriefConfigPage() {
}))
}
// 违禁词操作
const addBlacklistWord = () => {
if (!newBlacklistWord.trim()) return
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
blacklistWords: [...prev.blacklistWords, { id: `bw${Date.now()}`, word: newBlacklistWord, reason: '自定义' }]
}))
@@ -101,17 +220,45 @@ export default function BriefConfigPage() {
}
const removeBlacklistWord = (id: string) => {
setBrief(prev => ({
setAgencyConfig(prev => ({
...prev,
blacklistWords: prev.blacklistWords.filter(bw => bw.id !== id)
}))
}
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
alert('配置已保存!')
// 代理商文档操作
const handleUploadAgencyFile = async () => {
setIsUploading(true)
// 模拟上传
await new Promise(resolve => setTimeout(resolve, 1500))
const newFile: AgencyFile = {
id: `af${Date.now()}`,
name: '新上传文档.pdf',
size: '1.2MB',
uploadedAt: new Date().toISOString().split('T')[0],
description: '新上传的文档'
}
setAgencyConfig(prev => ({
...prev,
agencyFiles: [...prev.agencyFiles, newFile]
}))
setIsUploading(false)
alert('文档上传成功!')
}
const removeAgencyFile = (id: string) => {
setAgencyConfig(prev => ({
...prev,
agencyFiles: prev.agencyFiles.filter(f => f.id !== id)
}))
}
const handlePreviewAgencyFile = (file: AgencyFile) => {
setPreviewAgencyFile(file)
}
const handleDownloadAgencyFile = (file: AgencyFile) => {
alert(`下载文件: ${file.name}`)
}
return (
@@ -122,48 +269,214 @@ export default function BriefConfigPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{brief.projectName}</h1>
<p className="text-sm text-text-secondary">{brief.brandName}</p>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-text-primary">{brandBrief.projectName}</h1>
{platform && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
</div>
<p className="text-sm text-text-secondary flex items-center gap-2 mt-1">
<Building2 size={14} />
{brandBrief.brandName}
</p>
</div>
<Button variant="secondary" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={16} />
{isExporting ? '导出中...' : '导出规则'}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
<Save size={16} />
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
{/* ===== 第一部分:品牌方 Brief(只读)===== */}
<div className="p-4 bg-purple-500/10 rounded-lg border border-purple-500/30">
<div className="flex items-start gap-3">
<Building2 size={20} className="text-purple-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-purple-400 font-medium"> Brief</p>
<p className="text-sm text-purple-400/80 mt-1">
Brief
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:Brief 文件和 AI 解析 */}
<div className="lg:col-span-2 space-y-6">
{/* Brief 文件 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
Brief
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
{/* 品牌方文件 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<FileText size={18} className="text-purple-400" />
Brief
<span className="text-sm font-normal text-text-secondary">
{brandBrief.files.length}
</span>
</span>
<Button variant="secondary" size="sm" onClick={() => setShowFilesModal(true)}>
<Eye size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{brandBrief.files.slice(0, 2).map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<FileText size={32} className="text-accent-indigo" />
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={20} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary">{brief.fileName}</p>
<p className="text-sm text-text-secondary"> {brief.uploadedAt}</p>
<p className="font-medium text-text-primary text-sm">{file.name}</p>
<p className="text-xs text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm">
<Upload size={14} />
<Button variant="ghost" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{brandBrief.files.length > 2 && (
<button
type="button"
onClick={() => setShowFilesModal(true)}
className="w-full p-3 text-sm text-purple-400 hover:bg-purple-500/5 rounded-lg transition-colors"
>
{brandBrief.files.length}
</button>
)}
</CardContent>
</Card>
{/* 品牌方规则(只读) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<p className="text-sm text-text-primary">{brandBrief.brandRules.restrictions}</p>
</div>
<div>
<p className="text-xs text-text-tertiary mb-2"></p>
<div className="flex flex-wrap gap-2">
{brandBrief.brandRules.competitors.map((c, i) => (
<span key={i} className="px-2 py-1 text-xs bg-orange-500/15 text-orange-400 rounded border border-orange-500/30">
{c}
</span>
))}
</div>
</div>
</CardContent>
</Card>
</div>
{/* ===== 第二部分:代理商配置(可编辑)===== */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<p className="text-sm text-accent-indigo/80 mt-1">
</p>
</div>
</div>
</div>
{/* 代理商Brief文档管理 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<File size={18} className="text-accent-indigo" />
Brief
<span className="text-sm font-normal text-text-secondary">
{agencyConfig.agencyFiles.length}
</span>
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowAgencyFilesModal(true)}>
<Eye size={14} />
</Button>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="p-4 bg-accent-indigo/5 rounded-lg border border-accent-indigo/20 hover:border-accent-indigo/40 transition-colors">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text-primary text-sm truncate">{file.name}</p>
<p className="text-xs text-text-tertiary mt-0.5">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
<Button variant="ghost" size="sm" onClick={() => handlePreviewAgencyFile(file)} className="flex-1">
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownloadAgencyFile(file)} className="flex-1">
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{/* 上传占位卡片 */}
<button
type="button"
onClick={handleUploadAgencyFile}
disabled={isUploading}
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
>
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
<Plus size={20} className="text-text-tertiary" />
</div>
<span className="text-sm text-text-secondary"></span>
</button>
</div>
<div className="mt-4 p-3 bg-accent-indigo/10 rounded-lg border border-accent-indigo/20">
<p className="text-xs text-accent-indigo flex items-center gap-2">
<Info size={14} />
</p>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:AI解析 + 卖点配置 */}
<div className="lg:col-span-2 space-y-6">
{/* AI 解析结果 */}
<Card>
<CardHeader>
@@ -182,37 +495,33 @@ export default function BriefConfigPage() {
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.productName}</p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.productName}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.targetAudience}</p>
<p className="text-text-primary font-medium">{agencyConfig.aiParsedContent.targetAudience}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg col-span-2">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.contentRequirements}</p>
</div>
<div className="p-3 bg-bg-elevated rounded-lg col-span-2">
<p className="text-xs text-text-tertiary mb-1"></p>
<p className="text-text-primary">{brief.aiParsedContent.restrictions}</p>
<p className="text-text-primary">{agencyConfig.aiParsedContent.contentRequirements}</p>
</div>
</div>
</CardContent>
</Card>
{/* 卖点配置 */}
{/* 卖点配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target size={18} className="text-accent-green" />
<span className="text-sm font-normal text-text-secondary ml-2">
{brief.sellingPoints.length}
{agencyConfig.sellingPoints.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{brief.sellingPoints.map((sp) => (
{agencyConfig.sellingPoints.map((sp) => (
<div key={sp.id} className="flex items-center gap-3 p-3 bg-bg-elevated rounded-lg">
<button
type="button"
@@ -249,22 +558,53 @@ export default function BriefConfigPage() {
</div>
</CardContent>
</Card>
{/* 平台规则 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-amber" />
{rules.name}
</span>
<Button variant="secondary" size="sm" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={14} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rules.rules.map((rule, index) => (
<div key={index}>
<p className="text-sm font-medium text-text-primary mb-2">{rule.category}</p>
<div className="flex flex-wrap gap-2">
{rule.items.map((item, i) => (
<span key={i} className="px-2 py-1 text-xs bg-accent-amber/15 text-accent-amber rounded border border-accent-amber/30">
{item}
</span>
))}
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* 右侧:违禁词配置 */}
<div className="space-y-6">
{/* 违禁词配置(可编辑) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Ban size={18} className="text-accent-coral" />
<span className="text-sm font-normal text-text-secondary ml-2">
{brief.blacklistWords.length}
{agencyConfig.blacklistWords.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{brief.blacklistWords.map((bw) => (
{agencyConfig.blacklistWords.map((bw) => (
<div key={bw.id} className="flex items-center justify-between p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<div>
<span className="font-medium text-accent-coral">{bw.word}</span>
@@ -295,22 +635,193 @@ export default function BriefConfigPage() {
</CardContent>
</Card>
{/* 配置信息 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock size={18} className="text-text-tertiary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<SuccessTag></SuccessTag>
</div>
<div className="flex justify-between">
<span className="text-text-secondary"></span>
<span className="text-text-primary">{agencyConfig.configuredAt}</span>
</div>
</CardContent>
</Card>
{/* 配置提示 */}
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
<div className="p-4 bg-accent-green/10 rounded-lg border border-accent-green/30">
<div className="flex items-start gap-3">
<AlertTriangle size={20} className="text-accent-indigo flex-shrink-0 mt-0.5" />
<CheckCircle size={20} className="text-accent-green flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-accent-indigo font-medium"></p>
<ul className="text-xs text-accent-indigo/80 mt-1 space-y-1">
<p className="text-sm text-accent-green font-medium"></p>
<ul className="text-xs text-accent-green/80 mt-1 space-y-1">
<li> </li>
<li> AI </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
</div>
{/* 文件列表弹窗 */}
<Modal
isOpen={showFilesModal}
onClose={() => setShowFilesModal(false)}
title="品牌方 Brief 文件"
size="lg"
>
<div className="space-y-3">
{brandBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-purple-500/15 flex items-center justify-center">
<FileText size={24} className="text-purple-400" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
</div>
</Modal>
{/* 文件预览弹窗(品牌方) */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
{/* 代理商文档管理弹窗 */}
<Modal
isOpen={showAgencyFilesModal}
onClose={() => setShowAgencyFilesModal(false)}
title="管理代理商 Brief 文档"
size="lg"
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-text-secondary">
</p>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
</div>
<div className="space-y-3">
{agencyConfig.agencyFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<FileText size={24} className="text-accent-indigo" />
</div>
<div>
<p className="font-medium text-text-primary">{file.name}</p>
<p className="text-sm text-text-secondary">{file.size} · {file.uploadedAt}</p>
{file.description && (
<p className="text-xs text-text-tertiary mt-1">{file.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => handlePreviewAgencyFile(file)}>
<Eye size={14} />
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDownloadAgencyFile(file)}>
<Download size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeAgencyFile(file.id)} className="text-accent-coral hover:text-accent-coral">
<Trash2 size={14} />
</Button>
</div>
</div>
))}
{agencyConfig.agencyFiles.length === 0 && (
<div className="py-12 text-center">
<File size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary"></p>
<p className="text-sm text-text-tertiary mt-1"></p>
</div>
)}
</div>
</div>
</Modal>
{/* 代理商文档预览弹窗 */}
<Modal
isOpen={!!previewAgencyFile}
onClose={() => setPreviewAgencyFile(null)}
title={previewAgencyFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setPreviewAgencyFile(null)}>
</Button>
{previewAgencyFile && (
<Button onClick={() => handleDownloadAgencyFile(previewAgencyFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
</div>
)
}
+283 -40
View File
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
@@ -15,14 +16,37 @@ import {
Clock,
Check,
MoreVertical,
PlusCircle
PlusCircle,
UserCheck,
UserX,
Bot,
Settings,
CalendarClock,
Building2,
Eye
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { cn } from '@/lib/utils'
// 消息类型
type MessageType =
| 'appeal_quota_request' // 达人申请增加申诉次数
| 'task_submitted' // 达人提交了脚本/视频
| 'review_complete' // 品牌终审通过
| 'review_rejected' // 品牌终审驳回
| 'new_project' // 被品牌邀请参与项目
| 'warning' // 风险预警
| 'creator_accept' // 达人接受签约邀请
| 'creator_reject' // 达人拒绝签约邀请
| 'ai_review_complete' // AI审核完成,待代理商审核
| 'brand_config_updated' // 品牌方更新了配置
| 'task_deadline' // 任务截止提醒
| 'brand_brief_updated' // 品牌方更新了Brief
| 'system_notice' // 系统通知
interface Message {
id: string
type: string
type: MessageType
title: string
content: string
time: string
@@ -31,6 +55,11 @@ interface Message {
iconColor: string
bgColor: string
platform?: string
taskId?: string
projectId?: string
creatorName?: string
hasAction?: boolean
actionType?: 'review' | 'view' | 'config'
// 申诉次数请求专用字段
appealRequest?: {
creatorName: string
@@ -62,18 +91,48 @@ const mockMessages: Message[] = [
},
{
id: 'msg-002',
type: 'ai_review_complete',
title: 'AI审核完成',
content: '达人「小美护肤」的脚本【夏日护肤推广】已通过AI审核,请及时进行人工审核',
time: '10分钟前',
read: false,
icon: Bot,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
platform: 'xiaohongshu',
taskId: 'task-006',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-003',
type: 'creator_accept',
title: '达人已签约',
content: '达人「美妆达人小王」已接受您的签约邀请,可以开始分配任务',
time: '20分钟前',
read: false,
icon: UserCheck,
iconColor: 'text-accent-green',
bgColor: 'bg-accent-green/20',
creatorName: '美妆达人小王',
},
{
id: 'msg-004',
type: 'task_submitted',
title: '新脚本提交',
content: '达人「小美护肤」提交了「夏日护肤推广脚本」,请及时审核',
time: '10分钟前',
content: '达人「小美护肤」提交了「夏日护肤推广脚本」,请及时审核',
time: '30分钟前',
read: false,
icon: FileText,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
platform: 'xiaohongshu',
taskId: 'task-006',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-003',
id: 'msg-005',
type: 'appeal_quota_request',
title: '申诉次数申请',
content: '达人「美妆达人小王」申请增加「双11护肤品种草」的申诉次数',
@@ -91,75 +150,165 @@ const mockMessages: Message[] = [
},
},
{
id: 'msg-004',
id: 'msg-006',
type: 'task_deadline',
title: '任务即将截止',
content: '【XX品牌618推广】任务将于3天后截止,还有5位达人未提交脚本',
time: '1小时前',
read: false,
icon: CalendarClock,
iconColor: 'text-orange-400',
bgColor: 'bg-orange-500/20',
platform: 'douyin',
hasAction: true,
actionType: 'view',
},
{
id: 'msg-007',
type: 'review_complete',
title: '品牌终审通过',
content: '「新品口红试色」视频已通过品牌方终审',
content: '【XX品牌618推广】达人「美妆小红」的视频「新品口红试色」已通过品牌方终审,可通知达人发布',
time: '1小时前',
read: false,
icon: CheckCircle,
iconColor: 'text-accent-green',
bgColor: 'bg-accent-green/20',
platform: 'xiaohongshu',
taskId: 'task-004',
},
{
id: 'msg-005',
id: 'msg-008',
type: 'review_rejected',
title: '品牌终审驳回',
content: '「健身器材开箱」视频被品牌方驳回,原因:违禁词使用',
content: '【BB运动饮料】达人「健身教练王」的视频「健身器材开箱」被品牌方驳回,原因:违禁词使用,请通知达人修改',
time: '2小时前',
read: false,
icon: XCircle,
iconColor: 'text-accent-coral',
bgColor: 'bg-accent-coral/20',
platform: 'bilibili',
taskId: 'task-010',
},
{
id: 'msg-006',
id: 'msg-009',
type: 'brand_config_updated',
title: '品牌规则更新',
content: '品牌方「XX护肤品牌」更新了违禁词配置,新增8个违禁词',
time: '3小时前',
read: true,
icon: Settings,
iconColor: 'text-accent-amber',
bgColor: 'bg-accent-amber/20',
hasAction: true,
actionType: 'config',
},
{
id: 'msg-010',
type: 'brand_brief_updated',
title: 'Brief更新通知',
content: '品牌方更新了【XX品牌618推广】的Brief要求,请查看最新内容',
time: '4小时前',
read: true,
icon: FileText,
iconColor: 'text-accent-indigo',
bgColor: 'bg-accent-indigo/20',
projectId: 'proj-001',
hasAction: true,
actionType: 'view',
},
{
id: 'msg-011',
type: 'creator_reject',
title: '达人已拒绝',
content: '达人「时尚博主Anna」拒绝了您的签约邀请',
time: '昨天 14:30',
read: true,
icon: UserX,
iconColor: 'text-accent-coral',
bgColor: 'bg-accent-coral/20',
creatorName: '时尚博主Anna',
},
{
id: 'msg-012',
type: 'new_project',
title: '新项目邀请',
content: '您被邀请参与「XX品牌新品推广」项目,请配置 Brief',
content: '您被邀请参与「XX品牌新品推广」项目,请配置 Brief',
time: '昨天',
read: true,
icon: Users,
icon: Building2,
iconColor: 'text-purple-400',
bgColor: 'bg-purple-500/20',
platform: 'douyin',
projectId: 'proj-001',
hasAction: true,
actionType: 'config',
},
{
id: 'msg-007',
id: 'msg-013',
type: 'warning',
title: '风险预警',
content: '达人「美妆Lisa」连续2次提交被驳回,建议关注',
content: '达人「美妆Lisa」连续2次提交被驳回,建议关注并提供指导',
time: '昨天',
read: true,
icon: AlertTriangle,
iconColor: 'text-orange-400',
bgColor: 'bg-orange-500/20',
platform: 'xiaohongshu',
creatorName: '美妆Lisa',
},
{
id: 'msg-008',
id: 'msg-015',
type: 'task_submitted',
title: '新视频提交',
content: '达人「健身教练王」提交了「健身器材使用教程」视频,请及时审核',
content: '达人「健身教练王」提交了「健身器材使用教程」视频,请及时审核',
time: '2天前',
read: true,
icon: Video,
iconColor: 'text-purple-400',
bgColor: 'bg-purple-500/20',
platform: 'bilibili',
taskId: 'task-009',
hasAction: true,
actionType: 'review',
},
{
id: 'msg-016',
type: 'system_notice',
title: '系统通知',
content: '平台违禁词库已更新,「抖音」平台新增美妆类目违禁词56个',
time: '3天前',
read: true,
icon: Bell,
iconColor: 'text-text-secondary',
bgColor: 'bg-bg-elevated',
},
]
export default function AgencyMessagesPage() {
const router = useRouter()
const [messages, setMessages] = useState(mockMessages)
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
const unreadCount = messages.filter(m => !m.read).length
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
const pendingReviewCount = messages.filter(m =>
!m.read && (m.type === 'task_submitted' || m.type === 'ai_review_complete')
).length
const filteredMessages = filter === 'all' ? messages : messages.filter(m => !m.read)
const getFilteredMessages = () => {
switch (filter) {
case 'unread':
return messages.filter(m => !m.read)
case 'pending':
return messages.filter(m =>
m.type === 'task_submitted' || m.type === 'ai_review_complete' || m.type === 'appeal_quota_request'
)
default:
return messages
}
}
const filteredMessages = getFilteredMessages()
const markAsRead = (id: string) => {
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
@@ -186,6 +335,77 @@ export default function AgencyMessagesPage() {
}))
}
// 处理消息点击
const handleMessageClick = (message: Message) => {
if (message.type === 'appeal_quota_request') return // 申诉请求不跳转
if (message.type === 'system_notice') return // 系统通知不跳转
markAsRead(message.id)
// 根据消息类型决定跳转
switch (message.type) {
case 'creator_accept':
case 'creator_reject':
// 达人签约相关 -> 达人管理
router.push('/agency/creators')
break
case 'warning':
// 风险预警 -> 达人管理
router.push('/agency/creators')
break
case 'brand_config_updated':
// 品牌规则更新 -> Brief配置
router.push('/agency/briefs')
break
case 'task_deadline':
// 任务截止提醒 -> 任务列表
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else {
router.push('/agency/review')
}
break
default:
// 默认逻辑
if (message.taskId) {
router.push(`/agency/review/${message.taskId}`)
} else if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
}
}
}
// 处理操作按钮点击
const handleAction = (message: Message, e: React.MouseEvent) => {
e.stopPropagation()
markAsRead(message.id)
switch (message.actionType) {
case 'review':
if (message.taskId) {
router.push(`/agency/review/${message.taskId}`)
} else {
router.push('/agency/review')
}
break
case 'view':
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else if (message.type === 'task_deadline') {
router.push('/agency/review')
} else {
router.push('/agency/creators')
}
break
case 'config':
if (message.projectId) {
router.push(`/agency/briefs/${message.projectId}`)
} else {
router.push('/agency/briefs')
}
break
}
}
return (
<div className="space-y-6">
{/* 页面标题 */}
@@ -209,20 +429,32 @@ export default function AgencyMessagesPage() {
<button
type="button"
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={cn(
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
filter === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
)}
>
</button>
<button
type="button"
onClick={() => setFilter('unread')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={cn(
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
filter === 'unread' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
)}
>
({unreadCount})
({unreadCount})
</button>
<button
type="button"
onClick={() => setFilter('pending')}
className={cn(
'px-4 py-2 rounded-md text-sm font-medium transition-colors',
filter === 'pending' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
)}
>
</button>
</div>
@@ -237,26 +469,28 @@ export default function AgencyMessagesPage() {
return (
<Card
key={message.id}
className={`transition-all overflow-hidden ${
!isAppealRequest ? 'cursor-pointer hover:border-accent-indigo/50' : ''
} ${!message.read ? 'border-l-4 border-l-accent-indigo' : ''}`}
onClick={() => !isAppealRequest && markAsRead(message.id)}
className={cn(
'transition-all overflow-hidden',
!isAppealRequest && 'cursor-pointer hover:border-accent-indigo/50',
!message.read && 'border-l-4 border-l-accent-indigo'
)}
onClick={() => handleMessageClick(message)}
>
{/* 平台顶部条 */}
{platform && (
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<div className={cn('px-4 py-1.5 border-b flex items-center gap-1.5', platform.bgColor, platform.borderColor)}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
<span className={cn('text-xs font-medium', platform.textColor)}>{platform.name}</span>
</div>
)}
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className={`w-10 h-10 rounded-lg ${message.bgColor} flex items-center justify-center flex-shrink-0`}>
<div className={cn('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', message.bgColor)}>
<Icon size={20} className={message.iconColor} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className={`font-medium ${!message.read ? 'text-text-primary' : 'text-text-secondary'}`}>
<h3 className={cn('font-medium', !message.read ? 'text-text-primary' : 'text-text-secondary')}>
{message.title}
</h3>
{!message.read && (
@@ -275,10 +509,24 @@ export default function AgencyMessagesPage() {
)}
</div>
<p className="text-sm text-text-secondary mt-1">{message.content}</p>
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
<Clock size={12} />
{message.time}
</p>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{message.time}
</p>
{/* 操作按钮 */}
{message.hasAction && !isAppealRequest && (
<Button
variant="secondary"
size="sm"
onClick={(e) => handleAction(message, e)}
>
<Eye size={14} />
{message.actionType === 'review' ? '去审核' : message.actionType === 'config' ? '去配置' : '查看'}
</Button>
)}
</div>
{/* 申诉次数请求操作按钮 */}
{isAppealRequest && appealStatus === 'pending' && (
@@ -308,11 +556,6 @@ export default function AgencyMessagesPage() {
</div>
)}
</div>
{!isAppealRequest && (
<Button variant="ghost" size="sm" onClick={(e) => e.stopPropagation()}>
<MoreVertical size={16} />
</Button>
)}
</div>
</CardContent>
</Card>
+244 -75
View File
@@ -16,8 +16,10 @@ import {
ChevronRight,
Download,
Eye,
File
File,
MessageSquareWarning
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟脚本待审列表
@@ -34,6 +36,7 @@ const mockScriptTasks = [
riskLevel: 'low' as const,
submittedAt: '2026-02-06 14:30',
hasHighRisk: false,
isAppeal: false, // 是否为申诉
},
{
id: 'script-002',
@@ -47,6 +50,8 @@ const mockScriptTasks = [
riskLevel: 'medium' as const,
submittedAt: '2026-02-06 12:15',
hasHighRisk: true,
isAppeal: true, // 申诉重审
appealReason: '已修改违规用词,请求重新审核',
},
{
id: 'script-003',
@@ -60,6 +65,7 @@ const mockScriptTasks = [
riskLevel: 'low' as const,
submittedAt: '2026-02-06 10:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'script-004',
@@ -73,6 +79,8 @@ const mockScriptTasks = [
riskLevel: 'high' as const,
submittedAt: '2026-02-06 09:00',
hasHighRisk: true,
isAppeal: true,
appealReason: '对驳回原因有异议,内容符合要求',
},
]
@@ -91,6 +99,7 @@ const mockVideoTasks = [
duration: '02:15',
submittedAt: '2026-02-06 15:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'video-002',
@@ -105,6 +114,8 @@ const mockVideoTasks = [
duration: '03:42',
submittedAt: '2026-02-06 13:45',
hasHighRisk: true,
isAppeal: true,
appealReason: '已按要求重新剪辑,删除了争议片段',
},
{
id: 'video-003',
@@ -119,6 +130,7 @@ const mockVideoTasks = [
duration: '04:20',
submittedAt: '2026-02-06 11:30',
hasHighRisk: true,
isAppeal: false,
},
{
id: 'video-004',
@@ -133,6 +145,7 @@ const mockVideoTasks = [
duration: '01:45',
submittedAt: '2026-02-06 10:15',
hasHighRisk: false,
isAppeal: false,
},
]
@@ -152,13 +165,18 @@ function ScoreTag({ score }: { score: number }) {
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function ScriptTaskCard({ task }: { task: ScriptTask }) {
function ScriptTaskCard({ task, onPreview }: { task: ScriptTask; onPreview: (task: ScriptTask) => void }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: 实现实际下载逻辑
alert(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
@@ -168,6 +186,13 @@ function ScriptTaskCard({ task }: { task: ScriptTask }) {
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
@@ -180,53 +205,74 @@ function ScriptTaskCard({ task }: { task: ScriptTask }) {
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize}</p>
</div>
<button
type="button"
onClick={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览文件"
>
<Eye size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
function VideoTaskCard({ task }: { task: VideoTask }) {
function VideoTaskCard({ task, onPreview }: { task: VideoTask; onPreview: (task: VideoTask) => void }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: 实现实际下载逻辑
alert(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
@@ -236,6 +282,13 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
@@ -248,41 +301,57 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize} · {task.duration}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize} · {task.duration}</p>
</div>
<button
type="button"
onClick={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览视频"
>
<Eye size={18} className="text-text-secondary" />
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
@@ -291,6 +360,8 @@ function VideoTaskCard({ task }: { task: VideoTask }) {
export default function AgencyReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewScript, setPreviewScript] = useState<ScriptTask | null>(null)
const [previewVideo, setPreviewVideo] = useState<VideoTask | null>(null)
const filteredScripts = mockScriptTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -302,6 +373,10 @@ export default function AgencyReviewListPage() {
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 计算申诉数量
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
@@ -318,6 +393,12 @@ export default function AgencyReviewListPage() {
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{mockVideoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</span>
)}
</div>
</div>
@@ -381,7 +462,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<ScriptTaskCard key={task.id} task={task} />
<ScriptTaskCard key={task.id} task={task} onPreview={setPreviewScript} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@@ -408,7 +489,7 @@ export default function AgencyReviewListPage() {
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<VideoTaskCard key={task.id} task={task} />
<VideoTaskCard key={task.id} task={task} onPreview={setPreviewVideo} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@@ -420,6 +501,94 @@ export default function AgencyReviewListPage() {
</Card>
)}
</div>
{/* 脚本预览弹窗 */}
<Modal
isOpen={!!previewScript}
onClose={() => setPreviewScript(null)}
title={previewScript?.fileName || '脚本预览'}
size="lg"
>
<div className="space-y-4">
{previewScript?.isAppeal && previewScript?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewScript.appealReason}</p>
</div>
)}
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewScript?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewScript?.fileSize}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewScript(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewScript?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
{/* 视频预览弹窗 */}
<Modal
isOpen={!!previewVideo}
onClose={() => setPreviewVideo(null)}
title={previewVideo?.fileName || '视频预览'}
size="lg"
>
<div className="space-y-4">
{previewVideo?.isAppeal && previewVideo?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewVideo.appealReason}</p>
</div>
)}
<div className="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewVideo?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.fileSize}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.duration}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewVideo(null)}>
</Button>
<Button onClick={() => alert(`下载文件: ${previewVideo?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}
+85 -21
View File
@@ -16,8 +16,11 @@ import {
User,
Clock,
Eye,
Shield
Shield,
Download,
MessageSquareWarning
} from 'lucide-react'
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟脚本任务数据
const mockScriptTask = {
@@ -28,6 +31,19 @@ const mockScriptTask = {
submittedAt: '2026-02-06 14:30',
aiScore: 88,
status: 'agent_reviewing',
// 文件信息
file: {
id: 'file-001',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileUrl: '/demo/scripts/script-001.docx', // 实际开发时替换为真实URL
uploadedAt: '2026-02-06 14:30',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
// 脚本内容(AI解析后的结构化内容,用于展示)
scriptContent: {
opening: '大家好!今天给大家分享一款超级好用的夏日护肤神器~',
productIntro: '这款XX品牌的防晒霜,SPF50+,PA++++,真的是夏天出门必备!质地轻薄不油腻,涂上去清清爽爽的。',
@@ -80,7 +96,8 @@ export default function AgencyScriptReviewPage() {
const [showForcePassModal, setShowForcePassModal] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [forcePassReason, setForcePassReason] = useState('')
const [viewMode, setViewMode] = useState<'simple' | 'preview'>('preview')
const [viewMode, setViewMode] = useState<'file' | 'parsed'>('file') // 'file' 显示原文件, 'parsed' 显示解析内容
const [showFilePreview, setShowFilePreview] = useState(false)
const task = mockScriptTask
@@ -118,7 +135,15 @@ export default function AgencyScriptReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@@ -130,32 +155,63 @@ export default function AgencyScriptReviewPage() {
</span>
</div>
</div>
<button
type="button"
onClick={() => setViewMode(viewMode === 'simple' ? 'preview' : 'simple')}
className="flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated rounded-lg"
>
<Eye size={16} />
{viewMode === 'simple' ? '展开预览' : '简洁模式'}
</button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
<button
type="button"
onClick={() => setViewMode('file')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'file' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('parsed')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
viewMode === 'parsed' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
AI解析
</button>
</div>
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:脚本内容 */}
<div className="lg:col-span-2 space-y-4">
{viewMode === 'simple' ? (
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
{viewMode === 'file' ? (
<Card>
<CardContent className="py-8 text-center">
<FileText size={48} className="mx-auto text-accent-indigo mb-4" />
<p className="text-text-primary font-medium">{task.title}</p>
<p className="text-sm text-text-secondary mt-2">"展开预览"</p>
<Button variant="secondary" className="mt-4" onClick={() => setViewMode('preview')}>
<Eye size={16} />
</Button>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<FilePreview file={task.file} />
</CardContent>
</Card>
) : (
@@ -163,7 +219,8 @@ export default function AgencyScriptReviewPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
AI
<span className="text-xs font-normal text-text-tertiary ml-2">AI </span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -353,6 +410,13 @@ export default function AgencyScriptReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}
+84 -10
View File
@@ -17,8 +17,12 @@ import {
User,
Clock,
CheckCircle,
XCircle
XCircle,
Download,
ExternalLink,
MessageSquareWarning
} from 'lucide-react'
import { FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
// 模拟视频任务数据
const mockVideoTask = {
@@ -30,6 +34,20 @@ const mockVideoTask = {
duration: 135,
aiScore: 85,
status: 'agent_reviewing',
// 文件信息
file: {
id: 'file-video-001',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
fileType: 'video/mp4',
fileUrl: '/demo/videos/video-001.mp4', // 实际开发时替换为真实URL
uploadedAt: '2026-02-06 15:00',
duration: '02:15',
thumbnail: '/demo/videos/video-001-thumb.jpg',
} as FileInfo,
// 申诉信息
isAppeal: false,
appealReason: '',
hardViolations: [
{
id: 'v1',
@@ -105,6 +123,8 @@ export default function AgencyVideoReviewPage() {
const [forcePassReason, setForcePassReason] = useState('')
const [saveAsException, setSaveAsException] = useState(false)
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
const [showFilePreview, setShowFilePreview] = useState(false)
const [videoError, setVideoError] = useState(false)
const task = mockVideoTask
@@ -149,7 +169,15 @@ export default function AgencyVideoReviewPage() {
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-text-primary">{task.title}</h1>
{task.isAppeal && (
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={14} />
@@ -163,22 +191,61 @@ export default function AgencyVideoReviewPage() {
</div>
</div>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="p-4 rounded-xl bg-accent-amber/10 border border-accent-amber/30">
<p className="text-sm text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={14} />
</p>
<p className="text-text-secondary">{task.appealReason}</p>
</div>
)}
{/* 审核流程进度条 */}
<ReviewProgressBar taskStatus={task.status} />
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* 左侧:视频播放器 (3/5) */}
<div className="lg:col-span-3 space-y-4">
{/* 文件信息卡片 */}
<FileInfoCard
file={task.file}
onPreview={() => setShowFilePreview(true)}
/>
<Card>
<CardContent className="p-0">
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
<button
type="button"
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
onClick={() => setIsPlaying(!isPlaying)}
>
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
</button>
{/* 真实视频播放器 */}
<div className="aspect-video bg-gray-900 rounded-t-lg overflow-hidden relative">
{videoError ? (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Play size={48} className="mx-auto text-white/50 mb-3" />
<p className="text-white/70 mb-3"></p>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(task.file.fileUrl, '_blank')}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
) : (
<video
className="w-full h-full"
controls
poster={task.file.thumbnail}
onError={() => setVideoError(true)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={task.file.fileUrl} type={task.file.fileType} />
</video>
)}
</div>
{/* 智能进度条 */}
<div className="p-4 border-t border-border-subtle">
@@ -416,6 +483,13 @@ export default function AgencyVideoReviewPage() {
</div>
</div>
</Modal>
{/* 文件预览弹窗 */}
<FilePreviewModal
file={task.file}
isOpen={showFilePreview}
onClose={() => setShowFilePreview(false)}
/>
</div>
)
}