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