fix: refine brand knowledge image previews
This commit is contained in:
parent
ea14061331
commit
71e30662e5
@ -15,7 +15,7 @@ jest.mock('@/lib/brand-center-dashboard-access', () => ({
|
||||
},
|
||||
assertBrandCenterDashboardAccess: jest.fn(async () => undefined),
|
||||
}))
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { create: jest.fn(), findMany: jest.fn() } } }))
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { count: jest.fn(), create: jest.fn(), findMany: jest.fn() } } }))
|
||||
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(async () => false) }))
|
||||
jest.mock('@/lib/brand-knowledge-preview', () => ({
|
||||
prepareBrandKnowledgePreview: jest.fn(),
|
||||
@ -154,6 +154,7 @@ describe('brand knowledge upload route', () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(prisma.brandKnowledge.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(0)
|
||||
;(prepareBrandKnowledgePreview as jest.Mock).mockResolvedValue({
|
||||
source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 },
|
||||
slices: [Buffer.from('first-slice'), Buffer.from('second-slice')],
|
||||
@ -170,15 +171,20 @@ describe('brand knowledge upload route', () => {
|
||||
|
||||
test('list excludes soft-deleted knowledge and returns database-backed management permission', async () => {
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
||||
;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(23_081)
|
||||
|
||||
const response = await GET(new Request('http://localhost/api/department-dashboard/brand-knowledge'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.total).toBe(23_081)
|
||||
expect(body.permissions).toEqual({ canManage: true })
|
||||
expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ deletedAt: null }),
|
||||
}))
|
||||
expect(prisma.brandKnowledge.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ deletedAt: null }),
|
||||
})
|
||||
expect(isSuperAdmin).toHaveBeenCalledWith('user-1')
|
||||
})
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ export async function GET(request: Request) {
|
||||
const params = new URL(request.url).searchParams
|
||||
const type = params.get('type')?.trim() || ''
|
||||
const query = params.get('q')?.trim() || ''
|
||||
const [items, canManage] = await Promise.all([
|
||||
const [items, total, canManage] = await Promise.all([
|
||||
prisma.brandKnowledge.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
@ -133,10 +133,24 @@ export async function GET(request: Request) {
|
||||
_count: { select: { likes: true } },
|
||||
},
|
||||
}),
|
||||
prisma.brandKnowledge.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}),
|
||||
...(query ? {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ fileName: { contains: query, mode: 'insensitive' } },
|
||||
{ brand: { contains: query, mode: 'insensitive' } },
|
||||
{ productCategoryName: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
} : {}),
|
||||
},
|
||||
}),
|
||||
isSuperAdmin(userId),
|
||||
])
|
||||
return NextResponse.json(
|
||||
{ success: true, data: items.map(serializeKnowledge), permissions: { canManage } },
|
||||
{ success: true, data: items.map(serializeKnowledge), total, permissions: { canManage } },
|
||||
{ headers: PRIVATE_HEADERS },
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@ -63,6 +63,9 @@ describe('BrandKnowledgeDashboard', () => {
|
||||
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual(expect.arrayContaining([...BRAND_KNOWLEDGE_TYPE_OPTIONS]))
|
||||
expect(screen.getByRole('button', { name: '上传知识' })).toBeEnabled()
|
||||
await waitFor(() => expect(screen.getByText('品牌知识库暂未上传内容')).toBeInTheDocument())
|
||||
expect(screen.getByLabelText('共 0 条')).toHaveTextContent('共 0 条')
|
||||
expect(screen.queryByRole('heading', { name: '知识内容' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('人员上传的知识文件将按类型保存,并可在此下载')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the empty state by type and search', async () => {
|
||||
@ -81,6 +84,7 @@ describe('BrandKnowledgeDashboard', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
total: 23_081,
|
||||
data: [{
|
||||
id: 'knowledge-1',
|
||||
title: '索菲亚图像影像规范',
|
||||
@ -106,6 +110,7 @@ describe('BrandKnowledgeDashboard', () => {
|
||||
render(<BrandKnowledgeDashboard />)
|
||||
|
||||
const preview = await screen.findByRole('img', { name: '预览图:索菲亚图像影像规范' })
|
||||
expect(screen.getByLabelText('共 23081 条')).toHaveTextContent('共 23,081 条')
|
||||
expect(preview).toHaveClass('object-contain', 'object-center')
|
||||
expect(preview).not.toHaveClass('object-cover')
|
||||
expect(screen.getByRole('link', { name: '查看索菲亚图像影像规范详情' })).toHaveAttribute(
|
||||
@ -292,4 +297,27 @@ describe('BrandKnowledgeDashboard', () => {
|
||||
expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()
|
||||
expect(uploadButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('labels a processed preview with one output as a single image', async () => {
|
||||
mockPrepareBrandKnowledgePreviewInBrowser.mockResolvedValueOnce([{
|
||||
file: new File(['single preview'], '预览图.webp', { type: 'image/webp' }),
|
||||
mimeType: 'image/webp',
|
||||
width: 1_000,
|
||||
height: 3_000,
|
||||
kind: 'slice',
|
||||
sourceName: '预览图.png',
|
||||
sourceIndex: 0,
|
||||
sliceIndex: 0,
|
||||
sliceCount: 1,
|
||||
}])
|
||||
render(<BrandKnowledgeDashboard />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传知识' }))
|
||||
const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' })
|
||||
fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
|
||||
|
||||
expect(await screen.findByText('10 B · 单张预览')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('图片高度不超过 3000px,已保留为单张预览')
|
||||
expect(screen.queryByText(/已切为 1 片/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -379,6 +379,7 @@ export default function BrandKnowledgeDashboard() {
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)
|
||||
const [items, setItems] = useState<BrandKnowledgeItem[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [canManage, setCanManage] = useState(false)
|
||||
@ -390,7 +391,7 @@ export default function BrandKnowledgeDashboard() {
|
||||
|
||||
const loadKnowledge = useCallback(async () => {
|
||||
setIsLoading(true); setLoadError('')
|
||||
try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); setItems(Array.isArray(payload.data) ? payload.data : []); setCanManage(payload.permissions?.canManage === true) }
|
||||
try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); const nextItems = Array.isArray(payload.data) ? payload.data : []; setItems(nextItems); setTotalCount(Number.isSafeInteger(payload.total) && payload.total >= 0 ? payload.total : nextItems.length); setCanManage(payload.permissions?.canManage === true) }
|
||||
catch (error) { setLoadError(error instanceof Error ? error.message : '品牌知识列表读取失败,请稍后重试') }
|
||||
finally { setIsLoading(false) }
|
||||
}, [])
|
||||
@ -431,6 +432,7 @@ export default function BrandKnowledgeDashboard() {
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识删除失败,请稍后重试')
|
||||
setItems((current) => current.filter((item) => item.id !== id))
|
||||
setTotalCount((current) => Math.max(0, current - 1))
|
||||
showToast('品牌知识已删除,原文件已保留', { variant: 'success' })
|
||||
}, [showToast])
|
||||
|
||||
@ -491,12 +493,15 @@ export default function BrandKnowledgeDashboard() {
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section className="mt-6" aria-labelledby="brand-knowledge-content-title">
|
||||
<div className="flex flex-col items-start justify-between gap-3 border-b border-[#dfe4ea] pb-4 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<h2 id="brand-knowledge-content-title" className="text-lg font-bold text-[#101828]">知识内容</h2>
|
||||
<p className="mt-1 text-sm text-[#8a8f97]">人员上传的知识文件将按类型保存,并可在此下载</p>
|
||||
</div>
|
||||
<section className="mt-6" aria-label="品牌知识内容">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-[#dfe4ea] pb-4">
|
||||
<p
|
||||
className="text-sm text-[#86898e]"
|
||||
aria-label={isLoading ? '上传数量加载中' : `共 ${totalCount} 条`}
|
||||
aria-live="polite"
|
||||
>
|
||||
共 <span className="font-en text-base font-medium tabular-nums text-[#1a1c1d]">{isLoading ? '…' : totalCount.toLocaleString('zh-CN')}</span> 条
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
|
||||
@ -147,7 +147,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
|
||||
setPreviewSlices([])
|
||||
setPreviewUrl('')
|
||||
setPreviewUsesServerFallback(false)
|
||||
setPreviewProgress('正在浏览器后台处理长图并生成预览切片…')
|
||||
setPreviewProgress('正在浏览器后台生成安全预览…')
|
||||
setErrorMessage('')
|
||||
try {
|
||||
const slices = await prepareBrandKnowledgePreviewInBrowser(
|
||||
@ -160,7 +160,9 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
|
||||
if (previewProcessingVersionRef.current !== processingVersion) return
|
||||
setPreviewSlices(slices)
|
||||
setPreviewUrl(URL.createObjectURL(slices[0].file))
|
||||
setPreviewProgress(`已生成 ${slices.length} 个有序预览切片,上传后将连续展示`)
|
||||
setPreviewProgress(slices.length === 1
|
||||
? '图片高度不超过 3000px,已保留为单张预览'
|
||||
: `已生成 ${slices.length} 个有序预览切片,上传后将连续展示`)
|
||||
} catch (error) {
|
||||
if (previewProcessingVersionRef.current !== processingVersion || (error instanceof Error && error.name === 'AbortError')) return
|
||||
if (isPerformanceSurveyBrowserCapabilityError(error)
|
||||
@ -238,7 +240,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
|
||||
<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 > 0 ? `已切为 ${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-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={() => 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>
|
||||
|
||||
@ -2,6 +2,11 @@ import {
|
||||
canUseBrandKnowledgeLegacyServerPreview,
|
||||
prepareBrandKnowledgePreviewInBrowser,
|
||||
} from '@/lib/brand-knowledge-image-browser'
|
||||
import {
|
||||
BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT,
|
||||
planBrandKnowledgePreviewSlices,
|
||||
shouldSliceBrandKnowledgePreview,
|
||||
} from '@/lib/performance-survey-image-source-guard'
|
||||
|
||||
const mockPreparePerformanceSurveyImagesInBrowser = jest.fn()
|
||||
|
||||
@ -20,6 +25,21 @@ describe('brand knowledge preview browser processing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('uses 3000px as the inclusive single-image boundary', () => {
|
||||
expect(BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT).toBe(3_000)
|
||||
expect(shouldSliceBrandKnowledgePreview(2_999)).toBe(false)
|
||||
expect(shouldSliceBrandKnowledgePreview(3_000)).toBe(false)
|
||||
expect(shouldSliceBrandKnowledgePreview(3_001)).toBe(true)
|
||||
expect(planBrandKnowledgePreviewSlices(3_000, 1_200, 1_500)).toEqual({
|
||||
sliceHeightLimit: 1_200,
|
||||
sliceCount: 1,
|
||||
})
|
||||
expect(planBrandKnowledgePreviewSlices(3_001, 1_200, 1_500)).toEqual({
|
||||
sliceHeightLimit: 600,
|
||||
sliceCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('uses the dedicated 120MP brand profile and returns ordered slices', async () => {
|
||||
const controller = new AbortController()
|
||||
const source = new File(['image'], 'long.jpg', { type: 'image/jpeg' })
|
||||
|
||||
@ -8,6 +8,54 @@ import {
|
||||
} from '@/lib/brand-knowledge-preview'
|
||||
|
||||
describe('brand knowledge long preview processing', () => {
|
||||
test('keeps previews at or below 3000px as one image and slices above the boundary', async () => {
|
||||
const exactBoundary = await sharp({
|
||||
create: {
|
||||
width: 800,
|
||||
height: 3_000,
|
||||
channels: 3,
|
||||
background: { r: 238, g: 241, b: 244 },
|
||||
},
|
||||
}).png().toBuffer()
|
||||
const overBoundary = await sharp({
|
||||
create: {
|
||||
width: 800,
|
||||
height: 3_001,
|
||||
channels: 3,
|
||||
background: { r: 238, g: 241, b: 244 },
|
||||
},
|
||||
}).png().toBuffer()
|
||||
|
||||
const exactResult = await prepareBrandKnowledgePreview(exactBoundary, 'image/png')
|
||||
const overResult = await prepareBrandKnowledgePreview(overBoundary, 'image/png')
|
||||
|
||||
await expect(sharp(exactResult.slices[0]).metadata()).resolves.toMatchObject({
|
||||
format: 'webp', width: 800, height: 3_000,
|
||||
})
|
||||
expect(exactResult.slices).toHaveLength(1)
|
||||
expect(overResult.slices).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('still slices images above 3000px when width scaling makes the output shorter than 1500px', async () => {
|
||||
const wideOverBoundary = await sharp({
|
||||
create: {
|
||||
width: 4_000,
|
||||
height: 3_001,
|
||||
channels: 3,
|
||||
background: { r: 238, g: 241, b: 244 },
|
||||
},
|
||||
}).png().toBuffer()
|
||||
|
||||
const result = await prepareBrandKnowledgePreview(wideOverBoundary, 'image/png')
|
||||
const metadata = await Promise.all(result.slices.map((slice) => sharp(slice).metadata()))
|
||||
|
||||
expect(result.slices).toHaveLength(2)
|
||||
expect(metadata.map((item) => [item.width, item.height])).toEqual([
|
||||
[1_600, 600],
|
||||
[1_600, 600],
|
||||
])
|
||||
})
|
||||
|
||||
test('slices a long image into ordered WebP segments suitable for seamless rendering', async () => {
|
||||
const source = await sharp({
|
||||
create: {
|
||||
@ -66,6 +114,37 @@ describe('brand knowledge long preview processing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts one browser preview up to 3000px without treating it as a sliced image', async () => {
|
||||
const single = await sharp({
|
||||
create: { width: 1_000, height: 3_000, channels: 3, background: '#fff' },
|
||||
}).webp().toBuffer()
|
||||
|
||||
const result = await validateBrandKnowledgeUploadedPreviewSlices([
|
||||
{ buffer: single, declaredContentType: 'image/webp' },
|
||||
])
|
||||
|
||||
expect(result.slices.map((slice) => [slice.width, slice.height])).toEqual([[1_000, 3_000]])
|
||||
})
|
||||
|
||||
test('accepts a short equal-height pair created after scaling a wide source image', async () => {
|
||||
const first = await sharp({
|
||||
create: { width: 1_600, height: 600, channels: 3, background: '#fff' },
|
||||
}).webp().toBuffer()
|
||||
const last = await sharp({
|
||||
create: { width: 1_600, height: 600, channels: 3, background: '#fff' },
|
||||
}).webp().toBuffer()
|
||||
|
||||
const result = await validateBrandKnowledgeUploadedPreviewSlices([
|
||||
{ buffer: first, declaredContentType: 'image/webp' },
|
||||
{ buffer: last, declaredContentType: 'image/webp' },
|
||||
])
|
||||
|
||||
expect(result.slices.map((slice) => [slice.width, slice.height])).toEqual([
|
||||
[1_600, 600],
|
||||
[1_600, 600],
|
||||
])
|
||||
})
|
||||
|
||||
test('rejects forged or geometrically inconsistent browser slices', async () => {
|
||||
await expect(validateBrandKnowledgeUploadedPreviewSlices([
|
||||
{ buffer: Buffer.from('not a webp'), declaredContentType: 'image/webp' },
|
||||
@ -80,6 +159,11 @@ describe('brand knowledge long preview processing', () => {
|
||||
await expect(validateBrandKnowledgeUploadedPreviewSlices([
|
||||
{ buffer: shortFirst, declaredContentType: 'image/webp' },
|
||||
{ buffer: last, declaredContentType: 'image/webp' },
|
||||
])).rejects.toThrow('高度应为 1500px')
|
||||
])).rejects.toThrow('切片高度不一致')
|
||||
|
||||
await expect(validateBrandKnowledgeUploadedPreviewSlices([
|
||||
{ buffer: shortFirst, declaredContentType: 'image/webp' },
|
||||
{ buffer: shortFirst, declaredContentType: 'image/webp' },
|
||||
])).rejects.toThrow('切片高度不一致')
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
|
||||
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
|
||||
} from '@/lib/brand-knowledge-file-types'
|
||||
import {
|
||||
BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT,
|
||||
planBrandKnowledgePreviewSlices,
|
||||
} from '@/lib/performance-survey-image-source-guard'
|
||||
|
||||
configureSharpRuntime()
|
||||
|
||||
@ -71,11 +75,18 @@ export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredConte
|
||||
}
|
||||
|
||||
const source = assertBrandKnowledgePreviewMetadata(metadata, declaredContentType)
|
||||
const outputScale = Math.min(1, BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH / source.width)
|
||||
const outputHeight = Math.max(1, Math.round(source.height * outputScale))
|
||||
const previewPlan = planBrandKnowledgePreviewSlices(
|
||||
source.height,
|
||||
outputHeight,
|
||||
BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
|
||||
)
|
||||
let slices: Buffer[]
|
||||
try {
|
||||
slices = await sliceImage(
|
||||
buffer,
|
||||
BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
|
||||
previewPlan.sliceHeightLimit,
|
||||
BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB,
|
||||
BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH,
|
||||
{
|
||||
@ -111,6 +122,10 @@ export async function validateBrandKnowledgeUploadedPreviewSlices(
|
||||
return withImageProcessingSlot(async () => {
|
||||
const slices: Array<{ buffer: Buffer; width: number; height: number }> = []
|
||||
let expectedWidth = 0
|
||||
let expectedFullSliceHeight = 0
|
||||
const maxUploadedSliceHeight = uploadedSlices.length === 1
|
||||
? BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT
|
||||
: BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT
|
||||
|
||||
for (let index = 0; index < uploadedSlices.length; index += 1) {
|
||||
const uploaded = uploadedSlices[index]
|
||||
@ -126,7 +141,7 @@ export async function validateBrandKnowledgeUploadedPreviewSlices(
|
||||
metadata = await sharp(uploaded.buffer, {
|
||||
animated: true,
|
||||
failOn: 'error',
|
||||
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
|
||||
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * maxUploadedSliceHeight,
|
||||
sequentialRead: true,
|
||||
}).metadata()
|
||||
} catch {
|
||||
@ -139,13 +154,25 @@ export async function validateBrandKnowledgeUploadedPreviewSlices(
|
||||
throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`)
|
||||
}
|
||||
if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH
|
||||
|| height < 1 || height > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
|
||||
|| height < 1 || height > maxUploadedSliceHeight) {
|
||||
throw new Error(`预览图第 ${index + 1} 片尺寸无效`)
|
||||
}
|
||||
if (index === 0) expectedWidth = width
|
||||
if (index === 0) {
|
||||
expectedWidth = width
|
||||
expectedFullSliceHeight = height
|
||||
}
|
||||
if (width !== expectedWidth) throw new Error('预览图切片宽度不一致')
|
||||
if (index < uploadedSlices.length - 1 && height !== BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
|
||||
throw new Error(`预览图第 ${index + 1} 片高度应为 ${BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT}px`)
|
||||
if (uploadedSlices.length > 1) {
|
||||
const isLast = index === uploadedSlices.length - 1
|
||||
const isForcedShortPair = expectedFullSliceHeight <= Math.ceil(BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT / 2)
|
||||
const hasValidForcedPairGeometry = uploadedSlices.length === 2
|
||||
&& (!isLast || height === expectedFullSliceHeight || height === expectedFullSliceHeight - 1)
|
||||
const hasValidStandardGeometry = expectedFullSliceHeight === BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT
|
||||
&& (isLast ? height <= expectedFullSliceHeight : height === expectedFullSliceHeight)
|
||||
if ((isForcedShortPair && !hasValidForcedPairGeometry)
|
||||
|| (!isForcedShortPair && !hasValidStandardGeometry)) {
|
||||
throw new Error('预览图切片高度不一致')
|
||||
}
|
||||
}
|
||||
slices.push({ buffer: uploaded.buffer, width, height })
|
||||
}
|
||||
|
||||
@ -23,6 +23,31 @@ export const TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS = 60_000_000
|
||||
export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS = 120_000_000
|
||||
export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT = 90_000
|
||||
export const BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS = 60_000_000
|
||||
export const BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT = 3_000
|
||||
|
||||
export function shouldSliceBrandKnowledgePreview(sourceHeight: number) {
|
||||
return sourceHeight > BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT
|
||||
}
|
||||
|
||||
export function planBrandKnowledgePreviewSlices(
|
||||
sourceHeight: number,
|
||||
outputHeight: number,
|
||||
defaultSliceHeight: number,
|
||||
) {
|
||||
const safeOutputHeight = Math.max(1, Math.round(outputHeight))
|
||||
if (!shouldSliceBrandKnowledgePreview(sourceHeight)) {
|
||||
return { sliceHeightLimit: safeOutputHeight, sliceCount: 1 }
|
||||
}
|
||||
|
||||
const naturalSliceCount = Math.ceil(safeOutputHeight / defaultSliceHeight)
|
||||
const sliceHeightLimit = naturalSliceCount > 1
|
||||
? defaultSliceHeight
|
||||
: Math.max(1, Math.ceil(safeOutputHeight / 2))
|
||||
return {
|
||||
sliceHeightLimit,
|
||||
sliceCount: Math.ceil(safeOutputHeight / sliceHeightLimit),
|
||||
}
|
||||
}
|
||||
|
||||
export type PerformanceSurveySourceImageDimensions = {
|
||||
width: number
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS,
|
||||
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT,
|
||||
BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS,
|
||||
BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT,
|
||||
planBrandKnowledgePreviewSlices,
|
||||
} from './performance-survey-image-source-guard'
|
||||
import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error'
|
||||
|
||||
@ -130,9 +132,10 @@ async function renderSlice(
|
||||
scale: number,
|
||||
sliceIndex: number,
|
||||
sliceCount: number,
|
||||
sliceHeightLimit = SLICE_HEIGHT,
|
||||
) {
|
||||
const outputTop = sliceIndex * SLICE_HEIGHT
|
||||
const sliceHeight = Math.min(SLICE_HEIGHT, outputHeight - outputTop)
|
||||
const outputTop = sliceIndex * sliceHeightLimit
|
||||
const sliceHeight = Math.min(sliceHeightLimit, outputHeight - outputTop)
|
||||
const sourceTop = Math.floor(outputTop / scale)
|
||||
const sourceBottom = sliceIndex === sliceCount - 1
|
||||
? bitmap.height
|
||||
@ -194,7 +197,11 @@ async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight:
|
||||
}
|
||||
}
|
||||
|
||||
async function processSingleOriginal(file: File, includePreview = true): Promise<WorkerProcessedImage[]> {
|
||||
async function processSingleOriginal(
|
||||
file: File,
|
||||
includePreview = true,
|
||||
singleImageMaxSourceHeight?: number,
|
||||
): Promise<WorkerProcessedImage[]> {
|
||||
const bitmap = await decodeImage(file)
|
||||
try {
|
||||
if (!bitmap.width || !bitmap.height) {
|
||||
@ -203,7 +210,12 @@ async function processSingleOriginal(file: File, includePreview = true): Promise
|
||||
const scale = Math.min(1, outputWidthLimit / bitmap.width)
|
||||
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
|
||||
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
||||
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
|
||||
const brandKnowledgePlan = singleImageMaxSourceHeight
|
||||
? planBrandKnowledgePreviewSlices(bitmap.height, outputHeight, SLICE_HEIGHT)
|
||||
: null
|
||||
const sliceHeightLimit = brandKnowledgePlan?.sliceHeightLimit || SLICE_HEIGHT
|
||||
const sliceCount = brandKnowledgePlan?.sliceCount
|
||||
|| Math.max(1, Math.ceil(outputHeight / sliceHeightLimit))
|
||||
if (sliceCount > MAX_SLICES_PER_SOURCE) {
|
||||
throw new PerformanceSurveyBrowserImageError(
|
||||
'SOURCE_SLICE_LIMIT',
|
||||
@ -214,7 +226,15 @@ async function processSingleOriginal(file: File, includePreview = true): Promise
|
||||
const slices: WorkerProcessedImage[] = []
|
||||
|
||||
for (let sliceIndex = 0; sliceIndex < sliceCount; sliceIndex += 1) {
|
||||
const rendered = await renderSlice(bitmap, outputWidth, outputHeight, scale, sliceIndex, sliceCount)
|
||||
const rendered = await renderSlice(
|
||||
bitmap,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
scale,
|
||||
sliceIndex,
|
||||
sliceCount,
|
||||
sliceHeightLimit,
|
||||
)
|
||||
slices.push({
|
||||
...rendered,
|
||||
fileName: `${baseName}-${String(sliceIndex + 1).padStart(3, '0')}.webp`,
|
||||
@ -404,6 +424,9 @@ workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
: await processSingleOriginal(
|
||||
files[0],
|
||||
profile !== 'task_detail_detail' && profile !== 'brand_knowledge_preview',
|
||||
profile === 'brand_knowledge_preview'
|
||||
? BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT
|
||||
: undefined,
|
||||
)
|
||||
: await processOrderedSlices(files)
|
||||
workerScope.postMessage({ id, ok: true, images })
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user