484 lines
32 KiB
TypeScript
484 lines
32 KiB
TypeScript
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
import Image from 'next/image'
|
||
import { useCallback, 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_BRAND_OPTIONS,
|
||
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'
|
||
import {
|
||
prepareBrandKnowledgePreviewInBrowser,
|
||
canUseBrandKnowledgeLegacyServerPreview,
|
||
type BrandKnowledgeBrowserPreviewSlice,
|
||
} from '@/lib/brand-knowledge-image-browser'
|
||
import { chunkUpload, generateFileId } from '@/lib/chunk-upload'
|
||
import { isPerformanceSurveyBrowserCapabilityError } from '@/lib/performance-survey-image-error'
|
||
|
||
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 CompletedChunkUpload {
|
||
uploadId: string
|
||
existingFilePath: string
|
||
originalFileName: string
|
||
fileSize: number
|
||
}
|
||
|
||
interface PersistedCompletedChunkUpload extends CompletedChunkUpload {
|
||
completedAt: number
|
||
}
|
||
|
||
const COMPLETED_UPLOAD_STORAGE_PREFIX = 'brand-knowledge-completed-upload:v1:'
|
||
const COMPLETED_UPLOAD_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||
|
||
function completedUploadStorageKey(file: File) {
|
||
return `${COMPLETED_UPLOAD_STORAGE_PREFIX}${generateFileId(file)}`
|
||
}
|
||
|
||
function removePersistedCompletedUpload(file: File) {
|
||
try {
|
||
localStorage.removeItem(completedUploadStorageKey(file))
|
||
} catch {
|
||
// Browser storage can be unavailable in private or restricted contexts.
|
||
}
|
||
}
|
||
|
||
function persistCompletedUpload(file: File, upload: CompletedChunkUpload) {
|
||
try {
|
||
localStorage.setItem(completedUploadStorageKey(file), JSON.stringify({
|
||
...upload,
|
||
completedAt: Date.now(),
|
||
} satisfies PersistedCompletedChunkUpload))
|
||
} catch {
|
||
// Upload can still finish in the current dialog when storage is unavailable.
|
||
}
|
||
}
|
||
|
||
function restorePersistedCompletedUpload(file: File): CompletedChunkUpload | null {
|
||
try {
|
||
const key = completedUploadStorageKey(file)
|
||
const raw = localStorage.getItem(key)
|
||
if (!raw) return null
|
||
const parsed = JSON.parse(raw) as Partial<PersistedCompletedChunkUpload>
|
||
const valid = typeof parsed.uploadId === 'string'
|
||
&& /^upload-[A-Za-z0-9_-]+$/.test(parsed.uploadId)
|
||
&& typeof parsed.existingFilePath === 'string'
|
||
&& parsed.existingFilePath.length > 0
|
||
&& parsed.originalFileName === file.name
|
||
&& parsed.fileSize === file.size
|
||
&& typeof parsed.completedAt === 'number'
|
||
&& Date.now() - parsed.completedAt >= 0
|
||
&& Date.now() - parsed.completedAt <= COMPLETED_UPLOAD_MAX_AGE_MS
|
||
if (!valid) {
|
||
localStorage.removeItem(key)
|
||
return null
|
||
}
|
||
return {
|
||
uploadId: parsed.uploadId!,
|
||
existingFilePath: parsed.existingFilePath!,
|
||
originalFileName: parsed.originalFileName!,
|
||
fileSize: parsed.fileSize!,
|
||
}
|
||
} catch {
|
||
removePersistedCompletedUpload(file)
|
||
return null
|
||
}
|
||
}
|
||
|
||
interface UploadError extends Error {
|
||
status?: number
|
||
}
|
||
|
||
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() || ''
|
||
}
|
||
|
||
function formatUploadSpeed(bytesPerSecond: number | null) {
|
||
if (!bytesPerSecond || bytesPerSecond <= 0) return ''
|
||
return `${formatFileSize(bytesPerSecond)}/s`
|
||
}
|
||
|
||
function uploadErrorMessage(status: number, serverMessage?: string) {
|
||
if (status === 413) return '上传内容超过服务器允许大小(413)。请移除预览图后重试,或联系管理员检查上传限制。'
|
||
if (status === 429) return '当前上传任务较多(429),请稍后点击“重新上传”。'
|
||
if (status === 502) return '上传服务网关异常(502),请稍后点击“重新上传”。'
|
||
if (status === 503) return '上传服务暂不可用(503),请稍后点击“重新上传”。'
|
||
if (status === 504) return '服务器处理超时(504),请稍后点击“重新上传”。'
|
||
return serverMessage || '品牌知识上传失败,请稍后重试'
|
||
}
|
||
|
||
async function assertUploadResponse(response: Response) {
|
||
const payload = await response.json().catch(() => null)
|
||
if (response.ok && payload?.success) return
|
||
const error = new Error(uploadErrorMessage(response.status, payload?.error)) as UploadError
|
||
error.status = response.status
|
||
throw error
|
||
}
|
||
|
||
function thrownUploadErrorMessage(error: unknown, wasCancelled: boolean) {
|
||
if (wasCancelled || (error instanceof Error && (error.name === 'AbortError' || error.message === '上传已取消'))) {
|
||
return '上传已取消。文件和表单信息已保留,可点击“重新上传”。'
|
||
}
|
||
const status = error && typeof error === 'object' && 'status' in error
|
||
? Number((error as UploadError).status)
|
||
: 0
|
||
if (status > 0) return uploadErrorMessage(status, error instanceof Error ? error.message : undefined)
|
||
if (error instanceof TypeError) return '网络连接中断,请检查网络后点击“重新上传”。'
|
||
return error instanceof Error ? error.message : '品牌知识上传失败,请稍后重试'
|
||
}
|
||
|
||
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<string>(BRAND_KNOWLEDGE_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 [previewSlices, setPreviewSlices] = useState<BrandKnowledgeBrowserPreviewSlice[]>([])
|
||
const [previewUrl, setPreviewUrl] = useState('')
|
||
const [previewProgress, setPreviewProgress] = useState('')
|
||
const [previewUsesServerFallback, setPreviewUsesServerFallback] = useState(false)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [isPreviewDragging, setIsPreviewDragging] = useState(false)
|
||
const [isUploading, setIsUploading] = useState(false)
|
||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||
const [uploadSpeed, setUploadSpeed] = useState<number | null>(null)
|
||
const [uploadStageMessage, setUploadStageMessage] = useState('')
|
||
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)
|
||
const previewProcessingAbortRef = useRef<AbortController | null>(null)
|
||
const previewProcessingVersionRef = useRef(0)
|
||
const submitAbortRef = useRef<AbortController | null>(null)
|
||
const activeUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set())
|
||
const uploadCancelledRef = useRef(false)
|
||
const isUploadActiveRef = useRef(false)
|
||
const completedChunkUploadRef = useRef<CompletedChunkUpload | null>(null)
|
||
|
||
const cancelActiveUpload = useCallback(() => {
|
||
if (!isUploadActiveRef.current) return
|
||
uploadCancelledRef.current = true
|
||
submitAbortRef.current?.abort()
|
||
activeUploadXhrsRef.current.forEach((xhr) => xhr.abort())
|
||
activeUploadXhrsRef.current.clear()
|
||
setUploadStageMessage('正在取消上传…')
|
||
}, [])
|
||
|
||
const requestClose = useCallback(() => {
|
||
if (isUploadActiveRef.current) {
|
||
cancelActiveUpload()
|
||
return
|
||
}
|
||
onClose()
|
||
}, [cancelActiveUpload, onClose])
|
||
|
||
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()
|
||
requestClose()
|
||
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 () => {
|
||
previewProcessingAbortRef.current?.abort()
|
||
submitAbortRef.current?.abort()
|
||
activeUploadXhrsRef.current.forEach((xhr) => xhr.abort())
|
||
activeUploadXhrsRef.current.clear()
|
||
document.body.style.overflow = previousOverflow
|
||
document.removeEventListener('keydown', handleKeyDown)
|
||
returnFocusElement?.focus()
|
||
}
|
||
}, [requestClose, 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 (isUploadActiveRef.current) return
|
||
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
|
||
}
|
||
const restoredUpload = restorePersistedCompletedUpload(file)
|
||
completedChunkUploadRef.current = restoredUpload
|
||
setUploadProgress(null)
|
||
setUploadSpeed(null)
|
||
setUploadStageMessage(restoredUpload ? '已恢复这个文件完成的分片上传,提交时将直接保存知识信息' : '')
|
||
setErrorMessage('')
|
||
setSelectedFile(file)
|
||
}
|
||
|
||
const setPreview = async (file: File | undefined) => {
|
||
if (isUploadActiveRef.current) return
|
||
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
|
||
}
|
||
previewProcessingAbortRef.current?.abort()
|
||
previewProcessingVersionRef.current += 1
|
||
const processingVersion = previewProcessingVersionRef.current
|
||
const controller = new AbortController()
|
||
previewProcessingAbortRef.current = controller
|
||
setPreviewImage(file)
|
||
setPreviewSlices([])
|
||
setPreviewUrl('')
|
||
setPreviewUsesServerFallback(false)
|
||
setPreviewProgress('正在浏览器后台生成安全预览…')
|
||
setErrorMessage('')
|
||
try {
|
||
const slices = await prepareBrandKnowledgePreviewInBrowser(
|
||
file,
|
||
(label) => {
|
||
if (previewProcessingVersionRef.current === processingVersion) setPreviewProgress(label)
|
||
},
|
||
{ signal: controller.signal },
|
||
)
|
||
if (previewProcessingVersionRef.current !== processingVersion) return
|
||
setPreviewSlices(slices)
|
||
setPreviewUrl(URL.createObjectURL(slices[0].file))
|
||
setPreviewProgress(slices.length === 1
|
||
? '图片高度不超过 3000px,已保留为单张预览'
|
||
: `已生成 ${slices.length} 个有序预览切片,上传后将连续展示`)
|
||
} catch (error) {
|
||
if (previewProcessingVersionRef.current !== processingVersion || (error instanceof Error && error.name === 'AbortError')) return
|
||
if (isPerformanceSurveyBrowserCapabilityError(error)
|
||
&& await canUseBrandKnowledgeLegacyServerPreview(file)) {
|
||
if (previewProcessingVersionRef.current !== processingVersion) return
|
||
setPreviewUsesServerFallback(true)
|
||
setPreviewProgress('当前浏览器将由服务器生成普通图片预览')
|
||
return
|
||
}
|
||
setPreviewProgress('')
|
||
setErrorMessage(error instanceof Error ? error.message : '预览图处理失败,请重新选择图片')
|
||
} finally {
|
||
if (previewProcessingVersionRef.current === processingVersion) previewProcessingAbortRef.current = null
|
||
}
|
||
}
|
||
|
||
const removePreview = () => {
|
||
if (isUploadActiveRef.current) return
|
||
previewProcessingVersionRef.current += 1
|
||
previewProcessingAbortRef.current?.abort()
|
||
previewProcessingAbortRef.current = null
|
||
setPreviewImage(null)
|
||
setPreviewSlices([])
|
||
setPreviewUrl('')
|
||
setPreviewProgress('')
|
||
setPreviewUsesServerFallback(false)
|
||
setErrorMessage('')
|
||
}
|
||
|
||
const removeFile = () => {
|
||
if (isUploadActiveRef.current) return
|
||
if (selectedFile) removePersistedCompletedUpload(selectedFile)
|
||
completedChunkUploadRef.current = null
|
||
setSelectedFile(null)
|
||
setUploadProgress(null)
|
||
setUploadStageMessage('')
|
||
setErrorMessage('')
|
||
}
|
||
|
||
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()
|
||
|| (previewImage && previewSlices.length === 0 && !previewUsesServerFallback)) return
|
||
uploadCancelledRef.current = false
|
||
isUploadActiveRef.current = true
|
||
setIsUploading(true)
|
||
setUploadProgress(0)
|
||
setUploadSpeed(null)
|
||
setUploadStageMessage('正在分片上传主文件…')
|
||
setErrorMessage('')
|
||
try {
|
||
let completedChunkUpload = completedChunkUploadRef.current
|
||
if (completedChunkUpload
|
||
&& (completedChunkUpload.originalFileName !== selectedFile.name || completedChunkUpload.fileSize !== selectedFile.size)) {
|
||
completedChunkUpload = null
|
||
completedChunkUploadRef.current = null
|
||
}
|
||
|
||
if (!completedChunkUpload) {
|
||
const result = await chunkUpload(selectedFile, { uploadPurpose: 'brand-knowledge' }, {
|
||
maxRetries: 5,
|
||
onProgress: (progress, speed) => {
|
||
setUploadProgress(Math.max(0, Math.min(100, Math.round(progress))))
|
||
setUploadSpeed(speed || null)
|
||
},
|
||
onStageChange: (stage) => {
|
||
setUploadStageMessage(stage === 'processing' ? '主文件已上传,正在合并分片…' : '正在分片上传主文件…')
|
||
},
|
||
isCancelled: () => uploadCancelledRef.current,
|
||
onActiveXhrChange: (xhrs) => {
|
||
activeUploadXhrsRef.current = new Set(xhrs)
|
||
},
|
||
})
|
||
const uploadId = String(result?.uploadId || '').trim()
|
||
const existingFilePath = String(result?.existingFilePath || '').trim()
|
||
if (!uploadId || !existingFilePath) throw new Error('分片上传结果不完整,请点击“重新上传”')
|
||
completedChunkUpload = {
|
||
uploadId,
|
||
existingFilePath,
|
||
originalFileName: selectedFile.name,
|
||
fileSize: selectedFile.size,
|
||
}
|
||
completedChunkUploadRef.current = completedChunkUpload
|
||
persistCompletedUpload(selectedFile, completedChunkUpload)
|
||
}
|
||
|
||
if (uploadCancelledRef.current) throw new Error('上传已取消')
|
||
const formData = new FormData()
|
||
formData.set('title', title.trim())
|
||
formData.set('knowledgeType', knowledgeType)
|
||
formData.set('brand', brand)
|
||
formData.set('productCategoryName', productCategoryName)
|
||
if (completedChunkUpload) {
|
||
formData.set('uploadId', completedChunkUpload.uploadId)
|
||
formData.set('existingFilePath', completedChunkUpload.existingFilePath)
|
||
formData.set('originalFileName', completedChunkUpload.originalFileName)
|
||
formData.set('fileSize', String(completedChunkUpload.fileSize))
|
||
}
|
||
if (previewImage) {
|
||
if (previewUsesServerFallback) {
|
||
formData.set('previewImage', previewImage)
|
||
} else {
|
||
formData.set('previewImageName', previewImage.name)
|
||
formData.set('previewImageSliceCount', String(previewSlices.length))
|
||
previewSlices.forEach((slice) => formData.append('previewImageSlices', slice.file))
|
||
}
|
||
}
|
||
setUploadStageMessage(completedChunkUpload ? '主文件已上传,正在保存知识信息…' : '正在保存知识信息…')
|
||
if (completedChunkUpload) setUploadProgress(100)
|
||
setUploadSpeed(null)
|
||
const controller = new AbortController()
|
||
submitAbortRef.current = controller
|
||
const response = await fetch('/api/department-dashboard/brand-knowledge', {
|
||
method: 'POST',
|
||
body: formData,
|
||
signal: controller.signal,
|
||
})
|
||
await assertUploadResponse(response)
|
||
removePersistedCompletedUpload(selectedFile)
|
||
completedChunkUploadRef.current = null
|
||
await onUploaded()
|
||
onClose()
|
||
} catch (error) {
|
||
const status = error && typeof error === 'object' && 'status' in error
|
||
? Number((error as UploadError).status)
|
||
: 0
|
||
if (selectedFile && (status === 403 || status === 409)) {
|
||
removePersistedCompletedUpload(selectedFile)
|
||
completedChunkUploadRef.current = null
|
||
}
|
||
setErrorMessage(thrownUploadErrorMessage(error, uploadCancelledRef.current))
|
||
setUploadStageMessage('')
|
||
} finally {
|
||
isUploadActiveRef.current = false
|
||
submitAbortRef.current = null
|
||
activeUploadXhrsRef.current.clear()
|
||
setIsUploading(false)
|
||
setUploadSpeed(null)
|
||
}
|
||
}
|
||
|
||
const uploadSpeedLabel = formatUploadSpeed(uploadSpeed)
|
||
const statusMessage = errorMessage
|
||
|| uploadStageMessage
|
||
|| previewProgress
|
||
|| '文件和预览图将独立保存,不会写入素材资产库'
|
||
|
||
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) requestClose() }}>
|
||
<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={requestClose} aria-label={isUploading ? '取消当前上传' : '关闭上传知识窗口'} title={isUploading ? '取消上传' : '关闭'} 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={errorMessage ? 'alert' : 'status'} aria-live={errorMessage ? 'assertive' : '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 className="min-w-0"><p className="break-words font-semibold">{statusMessage}</p>{!errorMessage && !uploadStageMessage && !previewProgress && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 1GB;预览图支持 JPG、PNG、WebP,不超过 50MB,长图会自动切片并连续展示。</p>}</div></div>
|
||
|
||
{isUploading && uploadProgress !== null && <div className="space-y-2" role="progressbar" aria-label="主文件上传进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={uploadProgress} aria-valuetext={`${uploadProgress}%${uploadSpeedLabel ? `,${uploadSpeedLabel}` : ''}`}><div className="flex items-center justify-between gap-4 text-sm text-[#4f6077]"><span>主文件上传进度</span><span className="shrink-0 tabular-nums">{uploadProgress}%{uploadSpeedLabel ? ` · ${uploadSpeedLabel}` : ''}</span></div><div className="h-2 overflow-hidden rounded-full bg-[#e2e8f0]"><div className="h-full rounded-full bg-[#247fd3] transition-[width] duration-200" style={{ width: `${uploadProgress}%` }} /></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_KNOWLEDGE_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); void 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) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ? <div className="flex items-center gap-4"><div className="relative flex h-28 w-40 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#e2e8f0] bg-white">{previewUrl ? <Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-contain object-center" /> : <ImagePlus className="h-6 w-6 text-[#8a99ad]" aria-hidden="true" />}</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)} · {previewSlices.length === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}</p><button type="button" onClick={() => previewInputRef.current?.click()} disabled={Boolean(previewProgress && previewSlices.length === 0 && !previewUsesServerFallback)} 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] disabled:cursor-not-allowed disabled:opacity-55 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={removeFile} disabled={isUploading} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] disabled:cursor-not-allowed disabled:opacity-55 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} disabled={isUploading} 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()} disabled={isUploading} 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] disabled:cursor-not-allowed disabled:opacity-55 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()} disabled={isUploading} 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] disabled:cursor-not-allowed disabled:opacity-55 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={isUploading ? cancelActiveUpload : 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">{isUploading ? '取消上传' : '取消'}</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim() || Boolean(previewImage && previewSlices.length === 0 && !previewUsesServerFallback)} 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 ? '上传中…' : previewImage && previewSlices.length === 0 && !previewUsesServerFallback ? '正在处理预览图' : errorMessage ? '重新上传' : '提交知识'}</button></footer>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|