fix: improve material upload and category deletion
This commit is contained in:
parent
14db8a7816
commit
d16a2a201a
@ -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 || '图片上传失败')
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user