gongxiangp2/components/department-dashboard/BrandKnowledgeUploadDialog.tsx
2026-09-11 13:24:30 +08:00

191 lines
19 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 Image from 'next/image'
import { useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
import { AlertCircle, FileText, FolderOpen, ImagePlus, Upload, X } from 'lucide-react'
import {
BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT,
BRAND_KNOWLEDGE_MAIN_FILE_LABEL,
BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT,
BRAND_KNOWLEDGE_TYPE_OPTIONS,
} from '@/lib/brand-knowledge-file-types'
const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美']
const MAX_MAIN_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES
const MAX_PREVIEW_SIZE = BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES
const PREVIEW_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp'])
interface ProductCategory { id: string; name: string }
interface BrandKnowledgeUploadDialogProps {
onClose: () => void
onUploaded: () => void | Promise<void>
returnFocusRef: RefObject<HTMLButtonElement>
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function extensionOf(file: File) {
return file.name.split('.').pop()?.toLowerCase() || ''
}
export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, returnFocusRef }: BrandKnowledgeUploadDialogProps) {
const [knowledgeType, setKnowledgeType] = useState<string>(BRAND_KNOWLEDGE_TYPE_OPTIONS[0])
const [title, setTitle] = useState('')
const [brand, setBrand] = useState(BRAND_OPTIONS[0])
const [productCategoryName, setProductCategoryName] = useState('')
const [productCategories, setProductCategories] = useState<ProductCategory[]>([])
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [isPreviewDragging, setIsPreviewDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const dialogRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const previewDragDepthRef = useRef(0)
useEffect(() => {
const previousOverflow = document.body.style.overflow
const returnFocusElement = returnFocusRef.current
document.body.style.overflow = 'hidden'
closeButtonRef.current?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !dialogRef.current) return
const focusableElements = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'))
if (focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault(); lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault(); firstElement.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
returnFocusElement?.focus()
}
}, [onClose, returnFocusRef])
useEffect(() => {
let cancelled = false
void fetch('/api/product-categories', { cache: 'no-store' })
.then((response) => response.ok ? response.json() : [])
.then((payload) => {
if (!cancelled && Array.isArray(payload)) setProductCategories(payload.filter((item): item is ProductCategory => typeof item?.id === 'string' && typeof item?.name === 'string'))
})
.catch(() => undefined)
return () => { cancelled = true }
}, [])
useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
}, [previewUrl])
const setFile = (file: File | undefined) => {
if (!file) return
if (!BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT.split(',').includes(`.${extensionOf(file)}`)) {
setErrorMessage(`主文件格式不支持,请上传${BRAND_KNOWLEDGE_MAIN_FILE_LABEL}`)
return
}
if (file.size <= 0 || file.size > MAX_MAIN_FILE_SIZE) {
setErrorMessage('主文件需为 1B 至 1GB 之间的有效文件')
return
}
setErrorMessage('')
setSelectedFile(file)
}
const setPreview = (file: File | undefined) => {
if (!file) return
const extension = extensionOf(file)
if (!PREVIEW_EXTENSIONS.has(extension) || (file.type && !['image/jpeg', 'image/png', 'image/webp'].includes(file.type))) {
setErrorMessage('预览图仅支持 JPG、PNG 或 WebP 图片')
return
}
if (file.size <= 0 || file.size > MAX_PREVIEW_SIZE) {
setErrorMessage('预览图需为 1B 至 50MB 之间的有效图片')
return
}
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setErrorMessage('')
}
const removePreview = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewImage(null)
setPreviewUrl('')
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) }
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { dragDepthRef.current = 0; setIsDragging(false) } }
const handlePreviewDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); previewDragDepthRef.current += 1; setIsPreviewDragging(true) }
const handlePreviewDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); previewDragDepthRef.current -= 1; if (previewDragDepthRef.current <= 0) { previewDragDepthRef.current = 0; setIsPreviewDragging(false) } }
const handleSubmit = async () => {
if (isUploading || !selectedFile || !title.trim()) return
setIsUploading(true)
setErrorMessage('')
try {
const formData = new FormData()
formData.set('title', title.trim())
formData.set('knowledgeType', knowledgeType)
formData.set('brand', brand)
formData.set('productCategoryName', productCategoryName)
formData.set('file', selectedFile)
if (previewImage) formData.set('previewImage', previewImage)
const response = await fetch('/api/department-dashboard/brand-knowledge', { method: 'POST', body: formData })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识上传失败,请稍后重试')
await onUploaded()
onClose()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '品牌知识上传失败,请稍后重试')
} finally {
setIsUploading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="brand-knowledge-upload-title" aria-describedby="brand-knowledge-upload-status" className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:max-h-[calc(100dvh-3rem)]">
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭上传知识窗口" title="关闭" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-6 sm:px-8">
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB。</p>}</div></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ? <div className="flex items-center gap-4"><div className="relative h-28 w-40 shrink-0 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white"><Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-cover" /></div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 50MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>
<section aria-labelledby="brand-knowledge-file-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>{selectedFile && <button type="button" onClick={() => setSelectedFile(null)} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除文件</button>}</div><div onDragEnter={handleDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={fileInputRef} id="brand-knowledge-file" aria-label="选择知识文件" type="file" accept={BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT} onChange={(event) => setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ? <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm"><FileText className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换文件</button></div> : <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><Upload className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p><p className="mt-1 max-w-xl text-sm text-[#66758b]">支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择文件</button></div>}</div></section>
</div>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim()} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中...' : '提交知识'}</button></footer>
</div>
</div>
)
}