fix: refine brand knowledge image previews

This commit is contained in:
江林铖 2026-09-16 11:27:06 +08:00
parent ea14061331
commit 71e30662e5
10 changed files with 259 additions and 25 deletions

View File

@ -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')
})

View File

@ -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) {

View File

@ -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()
})
})

View File

@ -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

View File

@ -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>

View File

@ -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' })

View File

@ -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('切片高度不一致')
})
})

View File

@ -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 })
}

View File

@ -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

View File

@ -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 })