Your Name 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:11:54 +08:00

436 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState } 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'
import { api } from '@/lib/api'
// 文件信息类型
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 = async () => {
if (onDownload) {
onDownload()
} else {
try {
await api.downloadFile(file.fileUrl, file.fileName)
} catch {
// 回退到直接链接下载
const link = document.createElement('a')
link.href = file.fileUrl
link.download = file.fileName
link.click()
}
}
}
const handleOpenInNewTab = async () => {
try {
const blobUrl = await api.getPreviewUrl(file.fileUrl)
window.open(blobUrl, '_blank')
} catch {
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={async () => {
try {
const blobUrl = await api.getPreviewUrl(file.fileUrl)
window.open(blobUrl, '_blank')
} catch {
window.open(file.fileUrl, '_blank')
}
}}>
<ExternalLink size={16} />
</Button>
<Button onClick={async () => {
try {
await api.downloadFile(file.fileUrl, file.fileName)
} catch {
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={async () => {
try {
const blobUrl = await api.getPreviewUrl(file.fileUrl)
window.open(blobUrl, '_blank')
} catch {
window.open(file.fileUrl, '_blank')
}
}}>
<ExternalLink size={16} />
</Button>
<Button onClick={async () => {
try {
await api.downloadFile(file.fileUrl, file.fileName)
} catch {
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