fix: improve material upload and category deletion

This commit is contained in:
WuYongJie 2026-06-03 18:08:20 +08:00
parent 14db8a7816
commit d16a2a201a
3 changed files with 152 additions and 11 deletions

View File

@ -269,6 +269,117 @@ export async function uploadMaterialThumbnail(file: File, category?: string): Pr
})
}
const MATERIAL_UPLOAD_CLIENT_TARGET_BYTES = 900 * 1024
const MATERIAL_UPLOAD_TEXTURE_MAX_DIMENSION = 2048
const MATERIAL_UPLOAD_THUMBNAIL_MAX_DIMENSION = 768
function isClientCompressibleImage(file: File): boolean {
const type = file.type.toLowerCase()
const name = file.name.toLowerCase()
return (
type === 'image/jpeg' ||
type === 'image/png' ||
type === 'image/webp' ||
/\.(jpe?g|png|webp)$/.test(name)
)
}
function materialUploadFileName(fileName: string): string {
const baseName = fileName.replace(/\.[^.]+$/, '').trim() || 'material-texture'
return `${baseName}.webp`
}
async function loadImageElement(file: File): Promise<HTMLImageElement> {
const imageUrl = URL.createObjectURL(file)
try {
return await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('图片读取失败,请换用 JPG、PNG 或 WebP 图片'))
image.src = imageUrl
})
} finally {
URL.revokeObjectURL(imageUrl)
}
}
async function canvasToWebpBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), 'image/webp', quality)
})
}
async function compressMaterialImageForUpload(
file: File,
uploadType: 'thumbnail' | 'texture' | 'normal',
): Promise<File> {
if (
file.size <= MATERIAL_UPLOAD_CLIENT_TARGET_BYTES ||
typeof window === 'undefined' ||
typeof document === 'undefined' ||
typeof Image === 'undefined' ||
!isClientCompressibleImage(file)
) {
return file
}
const image = await loadImageElement(file)
const sourceWidth = image.naturalWidth || image.width
const sourceHeight = image.naturalHeight || image.height
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw new Error('图片尺寸读取失败,请换用 JPG、PNG 或 WebP 图片')
}
const maxDimension =
uploadType === 'thumbnail'
? MATERIAL_UPLOAD_THUMBNAIL_MAX_DIMENSION
: MATERIAL_UPLOAD_TEXTURE_MAX_DIMENSION
const dimensionSteps =
uploadType === 'thumbnail'
? [maxDimension, 640, 512, 384]
: [maxDimension, 1800, 1600, 1400, 1280, 1024, 768, 640]
const qualitySteps = [0.92, 0.86, 0.8, 0.72, 0.64, 0.56, 0.5, 0.42, 0.36]
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return file
}
let smallestBlob: Blob | null = null
for (const dimension of dimensionSteps) {
const scale = Math.min(1, dimension / Math.max(sourceWidth, sourceHeight))
canvas.width = Math.max(1, Math.round(sourceWidth * scale))
canvas.height = Math.max(1, Math.round(sourceHeight * scale))
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
for (const quality of qualitySteps) {
const blob = await canvasToWebpBlob(canvas, quality)
if (!blob) continue
if (!smallestBlob || blob.size < smallestBlob.size) {
smallestBlob = blob
}
if (blob.size <= MATERIAL_UPLOAD_CLIENT_TARGET_BYTES) {
return new File([blob], materialUploadFileName(file.name), {
type: 'image/webp',
lastModified: file.lastModified,
})
}
}
}
if (smallestBlob && smallestBlob.size < file.size && smallestBlob.size <= 1024 * 1024) {
return new File([smallestBlob], materialUploadFileName(file.name), {
type: 'image/webp',
lastModified: file.lastModified,
})
}
throw new Error('图片压缩后仍超过当前上传入口限制,请换用更小图片或先调大正式站上传限制')
}
export async function uploadMaterialAsset(
file: File,
options?: {
@ -276,9 +387,11 @@ export async function uploadMaterialAsset(
category?: string
},
): Promise<string> {
const uploadType = options?.type ?? 'texture'
const uploadFile = await compressMaterialImageForUpload(file, uploadType)
const formData = new FormData()
formData.append('file', file)
formData.append('type', options?.type ?? 'texture')
formData.append('file', uploadFile)
formData.append('type', uploadType)
if (options?.category) {
formData.append('category', options.category)
}
@ -290,6 +403,9 @@ export async function uploadMaterialAsset(
const payload = (await response.json().catch(() => null)) as ApiEnvelope<{ url: string }> | null
if (!response.ok || !payload?.success || payload.data === undefined) {
if (response.status === 413) {
throw new Error('图片超过当前站点上传入口限制,系统已尝试本地压缩;请换用更小图片或先调大正式站上传限制')
}
throw new Error(payload?.error?.message || '图片上传失败')
}

View File

@ -18,6 +18,7 @@ jest.mock('@/server/repositories/material.repository', () => ({
getNextSortOrder: jest.fn(),
create: jest.fn(),
update: jest.fn(),
deactivate: jest.fn(),
delete: jest.fn(),
},
}))
@ -63,6 +64,7 @@ const mockMaterialCategoryRepository = materialCategoryRepository as unknown as
getNextSortOrder: jest.Mock
create: jest.Mock
update: jest.Mock
deactivate: jest.Mock
delete: jest.Mock
}
@ -244,11 +246,27 @@ describe('materialService', () => {
expect(mockMaterialRepository.deactivate).not.toHaveBeenCalled()
})
it('删除分类时会硬删除未引用的分类', async () => {
it('删除分类时只按启用材质拦截并停用分类', async () => {
await materialService.deleteCategory('category-wood')
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', false)
expect(mockMaterialCategoryRepository.delete).toHaveBeenCalledWith('category-wood')
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', true)
expect(mockMaterialCategoryRepository.deactivate).toHaveBeenCalledWith('category-wood')
expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled()
})
it('彻底删除分类时会继续拦截历史隐藏材质', async () => {
mockMaterialCategoryRepository.getMaterialCount
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1)
await expect(materialService.deleteCategory('category-wood', true)).rejects.toThrow(
'该分类下还有 1 个历史材质,无法彻底删除',
)
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(1, 'category-wood', true)
expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(2, 'category-wood', false)
expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled()
expect(mockMaterialCategoryRepository.deactivate).not.toHaveBeenCalled()
})
it('创建分类时会归一化名称图标并拒绝负排序', async () => {

View File

@ -583,7 +583,7 @@ class MaterialService {
}
/**
* 删除分类(软删除)
* 删除分类:普通删除只校验启用材质,并停用分类以保留历史材质关联。
*/
async deleteCategory(id: string, hardDelete: boolean = false): Promise<void> {
// 检查分类是否存在
@ -592,22 +592,29 @@ class MaterialService {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '材质分类不存在')
}
// 检查分类下是否有材质
const materialCount = await materialCategoryRepository.getMaterialCount(id, false)
// 左侧分类数字只展示启用材质;删除校验也应按同一口径处理。
const activeMaterialCount = await materialCategoryRepository.getMaterialCount(id, true)
if (materialCount > 0) {
if (activeMaterialCount > 0) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
`该分类下还有 ${materialCount} 个材质,无法删除`
`该分类下还有 ${activeMaterialCount} 个材质,无法删除`
)
}
if (hardDelete) {
const materialCount = await materialCategoryRepository.getMaterialCount(id, false)
if (materialCount > 0) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
`该分类下还有 ${materialCount} 个历史材质,无法彻底删除`
)
}
await materialCategoryRepository.delete(id)
return
}
await materialCategoryRepository.delete(id)
await materialCategoryRepository.deactivate(id)
}
/**