feat: Add automatic PSD preview generation
- Add psd-processor library to auto-generate previews from PSD files - Update upload API to detect PSD files and generate previews automatically - Make preview image optional for PSD file uploads - Update UploadModal UI to show PSD files don't require manual preview upload - Install ag-psd package for PSD processing
This commit is contained in:
parent
d17f6df2c7
commit
e69f15337d
@ -74,7 +74,7 @@
|
||||
#### 用户特性
|
||||
```typescript
|
||||
用户属性:
|
||||
├── 基础信息(邮箱、用户名)
|
||||
├── 基础信息(邮箱、用户名、部门)
|
||||
├── 角色权限(普通用户/管理员)
|
||||
├── 激活状态(待激活/已激活)
|
||||
├── 下载权限(允许/禁止)
|
||||
@ -88,7 +88,16 @@
|
||||
#### 智能上传功能
|
||||
- 🎯 **拖拽上传** - 全窗口拖拽,自动识别文件类型
|
||||
- 📸 **预览图上传** - 支持JPG、PNG、WebP等格式
|
||||
- 📦 **压缩包上传** - 支持ZIP、RAR、7Z格式
|
||||
- 📦 **文件上传** - 支持压缩包和源文件
|
||||
- 压缩包格式:ZIP、RAR、7Z
|
||||
- 源文件格式:MAX、PSD、AI、CAD、DWG、C4D
|
||||
- 🤖 **智能识别** - 根据文件扩展名自动识别素材类型
|
||||
- .max → 自动设置为3DMAX
|
||||
- .psd → 自动设置为PSD
|
||||
- .ai → 自动设置为AI
|
||||
- .cad/.dwg → 自动设置为CAD
|
||||
- .c4d → 自动设置为C4D
|
||||
- 压缩包 → 需手动选择类型
|
||||
- 📊 **实时进度** - 上传进度和网络速度实时显示
|
||||
- 🚀 **大文件支持** - 最高支持10GB文件上传
|
||||
- 🔄 **智能压缩** - 预览图自动优化(最大1000x700px)
|
||||
@ -115,9 +124,18 @@
|
||||
2. 输入商品型号 → SPU标识
|
||||
3. 选择使用店铺 → 支持新建分类
|
||||
4. 选择场景类别 → 支持新建场景
|
||||
5. 选择素材类型 → 3DMAX/PSD/AI/CAD/C4D/其他
|
||||
6. 选择渲染器 → CR/VR/其它(仅3DMAX类型显示)
|
||||
7. 选择/拖拽压缩包 → 验证格式和大小
|
||||
5. 选择/拖拽文件 → 支持压缩包或源文件
|
||||
↓
|
||||
智能识别文件格式:
|
||||
- .max → 自动识别为3DMAX,显示渲染器选项
|
||||
- .psd → 自动识别为PSD
|
||||
- .ai → 自动识别为AI
|
||||
- .cad/.dwg → 自动识别为CAD
|
||||
- .c4d → 自动识别为C4D
|
||||
- .zip/.rar/.7z → 需手动选择素材类型
|
||||
↓
|
||||
6. 选择素材类型 → 已自动识别或手动选择
|
||||
7. 选择渲染器 → CR/VR/其它(仅3DMAX类型显示)
|
||||
8. 开始上传 → 实时显示进度和速度
|
||||
9. 上传完成 → 自动返回首页展示
|
||||
```
|
||||
@ -244,6 +262,13 @@
|
||||
- 📊 **使用统计** - 查看每个分类的素材数量
|
||||
- 🔄 **批量操作** - 支持批量修改分类
|
||||
|
||||
#### 部门管理
|
||||
- 🏢 **部门列表** - 查看所有部门
|
||||
- ➕ **新增部门** - 添加新的部门
|
||||
- 🗑️ **删除部门** - 删除不需要的部门
|
||||
- 👥 **用户关联** - 用户注册时选择所属部门
|
||||
- 📊 **部门统计** - 查看各部门用户数量
|
||||
|
||||
#### 系统设置(外观设置)
|
||||
- 🎨 **导航栏颜色** - 自定义顶部导航栏背景色
|
||||
- 颜色选择器实时预览
|
||||
@ -291,11 +316,23 @@
|
||||
- 👻 无感知(后台运行)
|
||||
- 📊 状态提示(实时显示)
|
||||
|
||||
#### 文件格式自动识别
|
||||
- 🤖 **智能识别** - 根据文件扩展名自动识别素材类型
|
||||
- 上传.max文件 → 自动设置为3DMAX类型
|
||||
- 上传.psd文件 → 自动设置为PSD类型
|
||||
- 上传.ai文件 → 自动设置为AI类型
|
||||
- 上传.cad/.dwg文件 → 自动设置为CAD类型
|
||||
- 上传.c4d文件 → 自动设置为C4D类型
|
||||
- 🎯 **准确高效** - 减少手动选择步骤,提升上传效率
|
||||
- 🔄 **灵活调整** - 自动识别后仍可手动修改类型
|
||||
- 📦 **压缩包支持** - 压缩包需手动选择类型(内容未知)
|
||||
- 🎨 **联动显示** - 识别为3DMAX时自动显示渲染器选项
|
||||
|
||||
#### 拖拽上传
|
||||
- 🎯 **全窗口拖拽区域** - 整个上传窗口都支持拖拽
|
||||
- 🎨 **视觉反馈** - 拖入时显示蓝色覆盖层提示
|
||||
- 🧠 **智能识别** - 自动区分图片和压缩包
|
||||
- 📁 **多文件支持** - 可同时拖入多个文件
|
||||
- 🧠 **智能识别** - 自动区分图片和文件
|
||||
- 📁 **多格式支持** - 支持压缩包和源文件拖拽
|
||||
- ✨ **传统兼容** - 保留点击选择文件方式
|
||||
|
||||
#### 图片预览
|
||||
@ -396,6 +433,7 @@
|
||||
├── email (String, UNIQUE) # 邮箱
|
||||
├── username (String, UNIQUE) # 用户名
|
||||
├── password (String) # 加密密码
|
||||
├── department (String) # 所属部门
|
||||
├── role (Enum) # USER/ADMIN
|
||||
├── isActive (Boolean) # 激活状态
|
||||
├── canDownload (Boolean) # 下载权限
|
||||
@ -476,6 +514,14 @@
|
||||
└── siteName: 由壹点3D素材
|
||||
```
|
||||
|
||||
**Department(部门表)**
|
||||
```sql
|
||||
部门表
|
||||
├── id (UUID, PK) # 唯一标识
|
||||
├── name (String, UNIQUE) # 部门名称
|
||||
└── createdAt (DateTime) # 创建时间
|
||||
```
|
||||
|
||||
### 存储策略
|
||||
|
||||
#### 混合存储方案
|
||||
@ -596,6 +642,29 @@ DELETE /api/admin/users # 删除用户
|
||||
Query: id (用户ID)
|
||||
```
|
||||
|
||||
**系统设置**
|
||||
```
|
||||
GET /api/system-settings # 获取系统设置
|
||||
返回: headerBgColor, logoUrl, siteName
|
||||
|
||||
PUT /api/system-settings # 更新系统设置(仅管理员)
|
||||
Body: { headerBgColor, siteName }
|
||||
|
||||
POST /api/system-settings/upload-logo # 上传LOGO(仅管理员)
|
||||
Body: FormData { logo: File }
|
||||
返回: { logoUrl }
|
||||
```
|
||||
|
||||
**部门管理**
|
||||
```
|
||||
GET /api/departments # 获取部门列表
|
||||
POST /api/departments # 创建部门(仅管理员)
|
||||
Body: { name: string }
|
||||
|
||||
DELETE /api/departments # 删除部门(仅管理员)
|
||||
Query: id (部门ID)
|
||||
```
|
||||
|
||||
**健康检查**
|
||||
```
|
||||
GET /api/health # 服务健康状态
|
||||
@ -766,6 +835,45 @@ WHERE [筛选条件];
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
### v2.5.0 - 系统设置与部门管理 (2025-10-26)
|
||||
|
||||
**新增功能**
|
||||
- 🎨 **外观设置**:自定义导航栏颜色、LOGO和网站名称
|
||||
- 10种预设颜色快速选择
|
||||
- 颜色选择器实时预览
|
||||
- LOGO上传到MinIO S3存储
|
||||
- 网站名称自定义修改
|
||||
- 🏢 **部门管理**:完整的部门管理系统
|
||||
- 部门列表查看和管理
|
||||
- 新增/删除部门功能
|
||||
- 用户注册时选择所属部门
|
||||
- 用户信息关联部门字段
|
||||
- 🤖 **文件格式自动识别**:智能识别上传文件类型
|
||||
- 支持.max/.psd/.ai/.cad/.dwg/.c4d等源文件格式
|
||||
- 根据文件扩展名自动设置素材类型
|
||||
- 减少手动选择步骤,提升上传效率
|
||||
- 自动识别3DMAX文件并显示渲染器选项
|
||||
- 🔄 **自动应用**:设置保存后自动刷新生效
|
||||
|
||||
**数据库变更**
|
||||
- 新增SystemSettings表(系统设置)
|
||||
- 新增Department表(部门管理)
|
||||
- User表新增department字段
|
||||
|
||||
**用户体验**
|
||||
- 管理员可完全自定义平台外观
|
||||
- 更好的组织架构管理
|
||||
- 上传素材更加智能便捷
|
||||
- 设置界面直观友好
|
||||
|
||||
**技术改进**
|
||||
- 新增系统设置API接口
|
||||
- 新增部门管理API接口
|
||||
- 完善文件格式识别逻辑
|
||||
- 完善前后端类型定义
|
||||
|
||||
---
|
||||
|
||||
### v2.4.0 - 多类型素材支持 (2025-10-26)
|
||||
|
||||
**新增功能**
|
||||
@ -1205,13 +1313,20 @@ module.exports = {
|
||||
- SPU:商品型号
|
||||
- 使用店铺:选择或新建
|
||||
- 场景类别:选择或新建
|
||||
- 素材类型:选择3DMAX/PSD/AI/CAD/C4D/其他
|
||||
- 渲染器:选择CR/VR/其它(仅3DMAX类型显示)
|
||||
4. 上传压缩包:
|
||||
4. 上传文件(支持源文件和压缩包):
|
||||
- 方式1:点击选择文件
|
||||
- 方式2:拖拽压缩包到窗口
|
||||
5. 点击"开始上传"
|
||||
6. 等待上传完成(查看进度和速度)
|
||||
- 方式2:拖拽文件到窗口
|
||||
- 📌 智能识别:
|
||||
* 上传.max文件 → 自动识别为3DMAX
|
||||
* 上传.psd文件 → 自动识别为PSD
|
||||
* 上传.ai文件 → 自动识别为AI
|
||||
* 上传.cad/.dwg文件 → 自动识别为CAD
|
||||
* 上传.c4d文件 → 自动识别为C4D
|
||||
* 上传压缩包 → 需手动选择类型
|
||||
5. 确认素材类型(已自动识别或手动调整)
|
||||
6. 选择渲染器(仅3DMAX类型显示):CR/VR/其它
|
||||
7. 点击"开始上传"
|
||||
8. 等待上传完成(查看进度和速度)
|
||||
```
|
||||
|
||||
#### 下载素材
|
||||
@ -1379,16 +1494,52 @@ const allowedArchiveTypes = [
|
||||
'application/x-7z-compressed'
|
||||
]
|
||||
|
||||
// 文件扩展名检查
|
||||
const allowedExtensions = ['.zip', '.rar', '.7z']
|
||||
// 文件扩展名检查(支持压缩包和源文件)
|
||||
const allowedExtensions = [
|
||||
// 压缩包格式
|
||||
'.zip', '.rar', '.7z',
|
||||
// 源文件格式
|
||||
'.max', // 3DMAX
|
||||
'.psd', // Photoshop
|
||||
'.ai', // Adobe Illustrator
|
||||
'.cad', '.dwg', // AutoCAD
|
||||
'.c4d' // Cinema 4D
|
||||
]
|
||||
|
||||
// 双重验证
|
||||
function validateFile(file: File) {
|
||||
const mimeValid = allowedTypes.includes(file.type)
|
||||
const extValid = allowedExtensions.some(ext =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
)
|
||||
return mimeValid && extValid
|
||||
const fileExtension = file.name.substring(
|
||||
file.name.lastIndexOf('.')
|
||||
).toLowerCase()
|
||||
|
||||
const extValid = allowedExtensions.includes(fileExtension)
|
||||
|
||||
if (!extValid) {
|
||||
throw new Error('不支持的文件格式')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 自动识别素材类型
|
||||
function detectMaterialType(filename: string): string | null {
|
||||
const ext = filename.substring(
|
||||
filename.lastIndexOf('.')
|
||||
).toLowerCase()
|
||||
|
||||
const typeMap = {
|
||||
'.max': '3DMAX',
|
||||
'.psd': 'PSD',
|
||||
'.ai': 'AI',
|
||||
'.cad': 'CAD',
|
||||
'.dwg': 'CAD',
|
||||
'.c4d': 'C4D',
|
||||
'.zip': null, // 需要手动选择
|
||||
'.rar': null,
|
||||
'.7z': null,
|
||||
}
|
||||
|
||||
return typeMap[ext] ?? '其他'
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@ import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
import { processImage, getProcessedFileName } from '@/lib/image-processor'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { generatePsdPreview, hasPsdExtension } from '@/lib/psd-processor'
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// Configure route to handle large files
|
||||
@ -50,11 +51,24 @@ export async function POST(req: NextRequest) {
|
||||
materialType
|
||||
})
|
||||
|
||||
// Validation
|
||||
if (!previewImage || !zipFile || !productModel || !categoryName || !sceneName) {
|
||||
console.log('[Upload] Validation failed - missing fields')
|
||||
// Check if file is PSD
|
||||
const isPsdFile = zipFile && hasPsdExtension(zipFile.name)
|
||||
console.log('[Upload] File type check:', { isPsdFile, fileName: zipFile?.name })
|
||||
|
||||
// Validation - preview image is optional for PSD files
|
||||
if (!zipFile || !productModel || !categoryName || !sceneName) {
|
||||
console.log('[Upload] Validation failed - missing required fields')
|
||||
return NextResponse.json(
|
||||
{ error: '所有字段都是必填的' },
|
||||
{ error: '产品型号、分类、场景和文件都是必填的' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// For non-PSD files, preview image is required
|
||||
if (!isPsdFile && !previewImage) {
|
||||
console.log('[Upload] Validation failed - non-PSD file requires preview image')
|
||||
return NextResponse.json(
|
||||
{ error: '请上传预览图,或者上传PSD文件以自动生成预览图' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@ -77,67 +91,148 @@ export async function POST(req: NextRequest) {
|
||||
const authorName = user.username
|
||||
console.log('[Upload] User found:', authorName)
|
||||
|
||||
// 验证预览图大小(限制50MB)
|
||||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||||
if (previewImage.size > maxPreviewSize) {
|
||||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return NextResponse.json(
|
||||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证预览图格式
|
||||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理预览图:压缩到300KB以内,转换为webP,缩放到600px
|
||||
console.log(`[Upload] Processing image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||||
let processedBuffer: Buffer
|
||||
try {
|
||||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||||
processedBuffer = await processImage(originalBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
format: 'webp',
|
||||
})
|
||||
console.log('[Upload] Image processed successfully')
|
||||
} catch (imageError) {
|
||||
console.error('[Upload] Image processing error:', imageError)
|
||||
return NextResponse.json(
|
||||
{ error: `图片处理失败,请确保图片文件未损坏且格式正确。错误:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
let previewFileName: string
|
||||
let previewUrl: string
|
||||
|
||||
// Handle preview image generation
|
||||
if (isPsdFile && !previewImage) {
|
||||
// Auto-generate preview from PSD file
|
||||
console.log('[Upload] PSD file detected, auto-generating preview...')
|
||||
|
||||
try {
|
||||
// Save PSD file temporarily
|
||||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
const tempPsdFileName = `temp-psd-${Date.now()}-${zipFile.name}`
|
||||
const tempPsdPath = path.join(uploadDir, tempPsdFileName)
|
||||
const psdBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||||
await writeFile(tempPsdPath, psdBuffer)
|
||||
console.log('[Upload] Temporary PSD saved:', tempPsdPath)
|
||||
|
||||
// Generate preview image
|
||||
const tempPreviewPath = path.join(uploadDir, `temp-preview-${Date.now()}.jpg`)
|
||||
const result = await generatePsdPreview(tempPsdPath, tempPreviewPath, 800)
|
||||
|
||||
if (!result.success || !result.previewPath) {
|
||||
// Clean up temp files
|
||||
await unlink(tempPsdPath).catch(() => {})
|
||||
await unlink(tempPreviewPath).catch(() => {})
|
||||
|
||||
console.error('[Upload] PSD preview generation failed:', result.error)
|
||||
return NextResponse.json(
|
||||
{ error: `PSD预览图生成失败:${result.error}。请手动上传预览图。` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('[Upload] PSD preview generated successfully')
|
||||
|
||||
// Read the generated preview and process it
|
||||
const { readFile } = await import('fs/promises')
|
||||
const generatedBuffer = await readFile(tempPreviewPath)
|
||||
|
||||
// Process the generated preview (compress and optimize)
|
||||
processedBuffer = await processImage(generatedBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
format: 'webp',
|
||||
})
|
||||
|
||||
// Clean up temporary files
|
||||
await unlink(tempPsdPath).catch(() => {})
|
||||
await unlink(tempPreviewPath).catch(() => {})
|
||||
console.log('[Upload] Temporary files cleaned up')
|
||||
|
||||
// Upload to S3
|
||||
previewFileName = `preview-${Date.now()}-generated.webp`
|
||||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||||
console.log('[Upload] Auto-generated preview uploaded to S3:', previewUrl)
|
||||
|
||||
} catch (psdError) {
|
||||
console.error('[Upload] PSD processing error:', psdError)
|
||||
return NextResponse.json(
|
||||
{ error: `PSD文件处理失败:${psdError instanceof Error ? psdError.message : '未知错误'}。请手动上传预览图。` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Use provided preview image
|
||||
if (!previewImage) {
|
||||
return NextResponse.json(
|
||||
{ error: '请上传预览图' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证预览图大小(限制50MB)
|
||||
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
|
||||
if (previewImage.size > maxPreviewSize) {
|
||||
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return NextResponse.json(
|
||||
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证预览图格式
|
||||
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!allowedImageTypes.includes(previewImage.type)) {
|
||||
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 处理预览图:压缩到300KB以内,转换为webP,缩放到600px
|
||||
console.log(`[Upload] Processing image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
|
||||
try {
|
||||
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
|
||||
processedBuffer = await processImage(originalBuffer, {
|
||||
maxSizeKB: 300,
|
||||
maxWidth: 600,
|
||||
format: 'webp',
|
||||
})
|
||||
console.log('[Upload] Image processed successfully')
|
||||
} catch (imageError) {
|
||||
console.error('[Upload] Image processing error:', imageError)
|
||||
return NextResponse.json(
|
||||
{ error: `图片处理失败,请确保图片文件未损坏且格式正确。错误:${imageError instanceof Error ? imageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Upload processed preview image to S3
|
||||
console.log('[Upload] Uploading image to S3...')
|
||||
const processedFileName = getProcessedFileName(previewImage.name)
|
||||
previewFileName = `preview-${Date.now()}-${processedFileName}`
|
||||
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
|
||||
console.log('[Upload] Image uploaded to S3:', previewUrl)
|
||||
}
|
||||
|
||||
// Upload processed preview image to S3
|
||||
console.log('[Upload] Uploading image to S3...')
|
||||
const processedFileName = getProcessedFileName(previewImage.name)
|
||||
const previewFileName = `preview-${Date.now()}-${processedFileName}`
|
||||
const previewUrl = await uploadToS3(
|
||||
processedBuffer,
|
||||
previewFileName,
|
||||
'image/webp'
|
||||
)
|
||||
console.log('[Upload] Image uploaded to S3:', previewUrl)
|
||||
|
||||
// Save zip file locally
|
||||
console.log('[Upload] Saving zip file locally...')
|
||||
// Save zip/PSD file locally
|
||||
console.log('[Upload] Saving file locally...')
|
||||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||||
await mkdir(uploadDir, { recursive: true })
|
||||
|
||||
const zipFileName = `zip-${Date.now()}-${zipFile.name}`
|
||||
const zipFilePath = path.join(uploadDir, zipFileName)
|
||||
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||||
|
||||
// If it's a PSD file, we already have the buffer from above
|
||||
// Otherwise, read the buffer now
|
||||
let zipBuffer: Buffer
|
||||
if (isPsdFile && !previewImage) {
|
||||
// Reread the PSD file for storage (we cleaned up the temp file already)
|
||||
zipBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||||
} else {
|
||||
zipBuffer = Buffer.from(await zipFile.arrayBuffer())
|
||||
}
|
||||
|
||||
await writeFile(zipFilePath, zipBuffer)
|
||||
const zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
|
||||
console.log('[Upload] Zip file saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
|
||||
console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
|
||||
|
||||
// Create material record
|
||||
console.log('[Upload] Creating database record...')
|
||||
|
||||
@ -272,8 +272,18 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!previewImage || !zipFile || !productModel || !categoryName || !sceneName) {
|
||||
setError('请填写所有字段')
|
||||
// Check if file is PSD
|
||||
const isPsdFile = zipFile && zipFile.name.toLowerCase().endsWith('.psd')
|
||||
|
||||
// Validation - preview is optional for PSD files
|
||||
if (!zipFile || !productModel || !categoryName || !sceneName) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
// For non-PSD files, preview is required
|
||||
if (!isPsdFile && !previewImage) {
|
||||
setError('请上传预览图,或者上传PSD文件以自动生成预览图')
|
||||
return
|
||||
}
|
||||
|
||||
@ -285,7 +295,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('previewImage', previewImage)
|
||||
// Only append preview image if it exists
|
||||
if (previewImage) {
|
||||
formData.append('previewImage', previewImage)
|
||||
}
|
||||
formData.append('zipFile', zipFile)
|
||||
formData.append('productModel', productModel)
|
||||
formData.append('categoryName', categoryName)
|
||||
@ -451,7 +464,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
{/* 预览图片 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
预览图片 *
|
||||
预览图片 {zipFile?.name.toLowerCase().endsWith('.psd') ? '(可选)' : '*'}
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{previewUrl ? (
|
||||
@ -487,6 +500,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
支持 JPG、PNG、WebP 等格式
|
||||
{zipFile?.name.toLowerCase().endsWith('.psd') && (
|
||||
<span className="block text-green-600 dark:text-green-400 mt-1">
|
||||
💡 PSD文件可自动生成预览图,无需手动上传
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
127
lib/psd-processor.ts
Normal file
127
lib/psd-processor.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { readPsd } from 'ag-psd'
|
||||
import sharp from 'sharp'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
interface PsdProcessResult {
|
||||
success: boolean
|
||||
previewPath?: string
|
||||
width?: number
|
||||
height?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从PSD文件生成预览图
|
||||
* @param psdFilePath PSD文件路径
|
||||
* @param outputPath 输出预览图路径
|
||||
* @param maxSize 最大尺寸(宽或高),默认800px
|
||||
* @returns 处理结果
|
||||
*/
|
||||
export async function generatePsdPreview(
|
||||
psdFilePath: string,
|
||||
outputPath: string,
|
||||
maxSize: number = 800
|
||||
): Promise<PsdProcessResult> {
|
||||
try {
|
||||
// 读取PSD文件
|
||||
const psdBuffer = await fs.readFile(psdFilePath)
|
||||
|
||||
// 解析PSD
|
||||
const psd = readPsd(psdBuffer, {
|
||||
skipLayerImageData: true, // 跳过图层数据,只读取合成图像
|
||||
skipThumbnail: false
|
||||
})
|
||||
|
||||
if (!psd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to parse PSD file'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取PSD的宽高
|
||||
const width = psd.width || 0
|
||||
const height = psd.height || 0
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid PSD dimensions'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果PSD有缩略图,优先使用缩略图
|
||||
if (psd.imageData) {
|
||||
const imageData = psd.imageData
|
||||
const channels = 4 // RGBA
|
||||
|
||||
// 将PSD图像数据转换为Buffer
|
||||
const rawBuffer = Buffer.from(imageData.data)
|
||||
|
||||
// 使用Sharp处理图像
|
||||
await sharp(rawBuffer, {
|
||||
raw: {
|
||||
width: imageData.width,
|
||||
height: imageData.height,
|
||||
channels: channels
|
||||
}
|
||||
})
|
||||
.resize(maxSize, maxSize, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({
|
||||
quality: 90,
|
||||
mozjpeg: true
|
||||
})
|
||||
.toFile(outputPath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
previewPath: outputPath,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'No image data found in PSD file'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating PSD preview:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否为PSD格式
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否为PSD文件
|
||||
*/
|
||||
export async function isPsdFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath, { encoding: null })
|
||||
// PSD文件签名:8BPS
|
||||
return buffer.length > 4 &&
|
||||
buffer[0] === 0x38 && // '8'
|
||||
buffer[1] === 0x42 && // 'B'
|
||||
buffer[2] === 0x50 && // 'P'
|
||||
buffer[3] === 0x53 // 'S'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件扩展名判断是否为PSD
|
||||
* @param filename 文件名
|
||||
* @returns 是否为PSD文件
|
||||
*/
|
||||
export function hasPsdExtension(filename: string): boolean {
|
||||
return filename.toLowerCase().endsWith('.psd')
|
||||
}
|
||||
|
||||
37
package-lock.json
generated
37
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.645.0",
|
||||
"@prisma/client": "^5.19.0",
|
||||
"ag-psd": "^28.4.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
@ -3341,6 +3342,16 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-psd": {
|
||||
"version": "28.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-psd/-/ag-psd-28.4.1.tgz",
|
||||
"integrity": "sha512-Cf5xQHScL8VUH2X2jjYbjv6NrZIJ7BkKNw5/FCE8wGv94yLQ4NyfRdvTXB0JJ0qC5IY1s+kVKJbA6JpO2n4hBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "1.5.1",
|
||||
"pako": "2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@ -3693,6 +3704,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.16",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
|
||||
@ -6725,6 +6756,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.645.0",
|
||||
"@prisma/client": "^5.19.0",
|
||||
"ag-psd": "^28.4.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user