gongxiangp2/components/department-dashboard/BrandKnowledgeUploadDialog.tsx

484 lines
32 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 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>
)
}