源码备份:反编译还原完成后的完整源码 (2026-01-16)

This commit is contained in:
chenpei 2026-01-16 16:19:44 +08:00
parent 562b7c6cdb
commit 6e4e72483b
180 changed files with 245455 additions and 408 deletions

View File

@ -5,14 +5,27 @@ out
build
dist
# 开发时需要的文件(运行时不需要)
# components - 保留,代码需要
types
next-env.d.ts
# 忽略的项目目录(不属于本项目)
flnxi_sucai
gongxiangp2
frp_*
# Docker 缓存(保留 npm-packages.tgz 用于加速构建)
.docker-cache/*
!.docker-cache/npm-packages.tgz
# 开发工具和缓存目录(完全排除)
.kiro
.kiro/**
.cursor
.cursor/**
.vscode
.idea
# Docker 缓存(完全排除,不需要在镜像中)
.docker-cache
.docker-cache/**
# 构建缓存(保留 node_modules 缓存)
.build-cache/*
@ -48,11 +61,17 @@ yarn-error.log*
# 压缩包和临时文件
*.tar.gz
*.tar
*.zip
*.rar
*.7z
project*.tar.gz
frp*.tar.gz
.tmp-*.tar*
youyidian-3d-material*.tar
nextjs-*.tar.gz
api-compiled
nextjs-build
# 开发工具配置
.vscode
@ -90,6 +109,14 @@ connect-nas.sh
deploy.sh
auto-deploy.sh
install.sh
*.ps1
scripts
scripts/**
__tests__
*.test.ts
*.test.js
*.spec.ts
*.spec.js
# 其他临时文件
.cache
@ -100,3 +127,100 @@ temp
*.seed
*.pid.lock
# SQL 文件和数据文件
*.sql
*.csv
*.json
!package.json
!package-lock.json
!tsconfig.json
!next.config.js
!tailwind.config.ts
!postcss.config.js
!jest.config.js
!jest.setup.js
*.txt
!README.md
# 旧的 JS 文件(不在 app 或 components 中)
shadow_*.js
*_page*.js
delete-text-materials.js
find-text-materials.js
# 部署和配置文件(不需要在容器中)
docker-compose*.yml
deploy-*.ps1
check-*.ps1
sync-*.ps1
verify-*.ps1
install-*.ps1
quick-*.ps1
cleanup-*.ps1
restore-*.sh
run-*.ps1
test-*.ps1
# 报告和分析文件
*-report.csv
*-report.json
*-stats*.csv
*-stats*.txt
*-inventory*.csv
*-inventory*.json
material-inventory-*.csv
material-inventory-*.json
old-materials-*.csv
missing-*.csv
missing-*.json
missing-*.txt
temp_*.sql
# SQL 文件和数据文件
*.sql
*.csv
*.json
!package.json
!package-lock.json
!tsconfig.json
!next.config.js
!tailwind.config.ts
!postcss.config.js
!jest.config.js
!jest.setup.js
*.txt
!README.md
# 旧的 JS 文件(不在 app 或 components 中)
shadow_*.js
*_page*.js
delete-text-materials.js
find-text-materials.js
# 部署和配置文件(不需要在容器中)
docker-compose*.yml
deploy-*.ps1
check-*.ps1
sync-*.ps1
verify-*.ps1
install-*.ps1
quick-*.ps1
cleanup-*.ps1
restore-*.sh
run-*.ps1
test-*.ps1
# 报告和分析文件
*-report.csv
*-report.json
*-stats*.csv
*-stats*.txt
*-inventory*.csv
*-inventory*.json
material-inventory-*.csv
material-inventory-*.json
old-materials-*.csv
missing-*.csv
missing-*.json
missing-*.txt
temp_*.sql

View File

@ -34,7 +34,15 @@ Next.js 14 + React 18 + TypeScript + TailwindCSS + Prisma + PostgreSQL + MinIO S
---
## ⚠️ 新老素材存储(核心)
## SSH 远程调用
PowerShell 中使用 plink 执行远程命令:
```powershell
& "C:\Program Files\PuTTY\plink.exe" -batch -pw "密码" root@IP地址 '命令'
```
注意:中文路径有编码问题,用通配符或 `sed -n Np` 定位
---
**分界点:2025-10-27**
@ -96,4 +104,4 @@ VSCode: `terminal.integrated.defaultProfile.windows: "Git Bash"`
---
*更新: 2025-12-27*
*更新: 2025-01-15*

205
Docker安装指南.md Normal file
View File

@ -0,0 +1,205 @@
# Docker Desktop for Windows 安装指南
## 📋 系统要求
- **操作系统**:Windows 10 专业版/企业版/教育版(版本 2004 或更高)或 Windows 11
- **硬件**:64 位处理器,支持虚拟化(SLAT)
- **内存**:至少 4GB(推荐 8GB 或更高)
- **虚拟化**:需要在 BIOS/UEFI 中启用硬件虚拟化
## 🚀 安装步骤
### 步骤 1:检查 WSL 2 是否已安装
WSL 2(Windows Subsystem for Linux 2)是 Docker Desktop 在 Windows 上的推荐后端。
1. **以管理员身份打开 PowerShell**(右键点击 PowerShell,选择"以管理员身份运行")
2. **检查 WSL 状态**:
```powershell
wsl --status
```
3. **如果未安装 WSL 2**,运行以下命令安装:
```powershell
wsl --install
```
这会安装 WSL 和默认的 Linux 发行版(通常是 Ubuntu)
4. **设置 WSL 2 为默认版本**:
```powershell
wsl --set-default-version 2
```
5. **重启计算机**(安装 WSL 后需要重启)
### 步骤 2:启用虚拟化功能(如果需要)
如果您的系统不支持 WSL 2,需要启用 Hyper-V:
1. **以管理员身份打开 PowerShell**
2. **启用 Hyper-V**:
```powershell
dism.exe /online /enable-feature /featurename:Microsoft-Hyper-V /all /norestart
```
3. **重启计算机**
### 步骤 3:下载 Docker Desktop
1. **访问 Docker 官网**:
- 打开浏览器,访问:https://www.docker.com/products/docker-desktop/
- 或者直接下载链接:https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe
2. **下载安装程序**:
- 点击 "Download for Windows" 按钮
- 下载 `Docker Desktop Installer.exe` 文件
### 步骤 4:安装 Docker Desktop
1. **运行安装程序**:
- 双击下载的 `Docker Desktop Installer.exe`
- 如果出现用户账户控制提示,点击"是"
2. **安装选项**:
- ✅ 勾选 "Use WSL 2 instead of Hyper-V"(如果已安装 WSL 2)
- ✅ 勾选 "Add shortcut to desktop"(可选,添加桌面快捷方式)
- 点击 "Ok" 开始安装
3. **等待安装完成**:
- 安装过程可能需要几分钟
- 安装完成后,点击 "Close and restart" 或手动重启计算机
### 步骤 5:启动和配置 Docker Desktop
1. **启动 Docker Desktop**:
- 从开始菜单或桌面快捷方式启动 Docker Desktop
- 首次启动可能需要几分钟来初始化
2. **接受服务条款**:
- 阅读并接受 Docker 服务条款
3. **配置设置**(可选):
- 在 Docker Desktop 设置中,可以调整:
- **Resources**:分配 CPU 和内存(推荐至少 4GB 内存)
- **General**:启动选项、更新设置等
- **Docker Engine**:Docker 引擎配置
4. **等待 Docker 启动完成**:
- 等待系统托盘中的 Docker 图标变为运行状态(绿色)
- 状态栏显示 "Docker Desktop is running"
### 步骤 6:验证安装
1. **打开 PowerShell**(普通用户权限即可)
2. **检查 Docker 版本**:
```powershell
docker --version
```
应该显示类似:`Docker version 24.x.x, build xxxxx`
3. **检查 Docker Compose 版本**:
```powershell
docker compose version
```
应该显示 Docker Compose 版本信息
4. **测试 Docker 运行**:
```powershell
docker run hello-world
```
如果看到 "Hello from Docker!" 消息,说明安装成功!
## ✅ 验证部署脚本所需功能
安装完成后,验证 `deploy-local-build.ps1` 脚本所需的功能:
```powershell
# 1. 检查 Docker 是否运行
docker ps
# 2. 测试构建功能(可选,测试构建一个小镜像)
docker build --help
# 3. 检查 Docker BuildKit(脚本中会启用)
$env:DOCKER_BUILDKIT = "1"
docker buildx version
```
## 🔧 常见问题
### 问题 1:WSL 2 安装失败
**解决方案**:
- 确保 Windows 已更新到最新版本
- 在 BIOS/UEFI 中启用虚拟化功能(VT-x 或 AMD-V)
- 检查是否启用了 Hyper-V(可能与某些虚拟化软件冲突)
### 问题 2:Docker Desktop 启动失败
**解决方案**:
- 确保 WSL 2 已正确安装:`wsl --status`
- 重启计算机
- 检查 Windows 功能中是否启用了"虚拟机平台"和"适用于 Linux 的 Windows 子系统"
### 问题 3:Docker 命令无法识别
**解决方案**:
- 确保 Docker Desktop 正在运行(系统托盘图标为绿色)
- 重启 PowerShell 或命令提示符
- 检查环境变量 PATH 中是否包含 Docker 路径
### 问题 4:构建镜像时内存不足
**解决方案**:
- 在 Docker Desktop 设置中增加分配的内存(Settings > Resources > Advanced)
- 关闭其他占用内存的程序
- 确保系统有足够的可用内存
## 📝 安装后配置建议
1. **分配资源**:
- 打开 Docker Desktop > Settings > Resources
- 建议分配:
- CPU:至少 2 核(推荐 4 核)
- Memory:至少 4GB(推荐 8GB)
- Disk image size:至少 60GB(根据项目需要调整)
2. **启用 BuildKit**(脚本会自动启用):
- Docker Desktop 默认已启用 BuildKit
- 可以通过环境变量 `DOCKER_BUILDKIT=1` 启用
3. **配置镜像加速**(可选,国内用户推荐):
- 在 Docker Desktop > Settings > Docker Engine 中添加:
```json
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com"
]
}
```
## 🎯 下一步
安装完成后,您就可以使用 `deploy-local-build.ps1` 脚本了:
```powershell
# 部署到正式系统
.\deploy-local-build.ps1 -Target production
# 部署到影子系统
.\deploy-local-build.ps1 -Target shadow
```
## 📚 参考资源
- Docker Desktop 官方文档:https://docs.docker.com/desktop/install/windows-install/
- WSL 2 官方文档:https://docs.microsoft.com/zh-cn/windows/wsl/
- Docker 官方下载:https://www.docker.com/products/docker-desktop/
---
**安装完成后,请运行验证命令确认 Docker 正常工作!**

437
GUID引导重建指南.md Normal file
View File

@ -0,0 +1,437 @@
# GUID引导重建指南
## 概述
GUID分区表(GPT)引导重建通常用于修复以下问题:
- GPT分区表损坏
- EFI系统分区(ESP)损坏或丢失
- UEFI引导记录损坏
- 系统无法启动
---
## 一、Windows环境重建GUID引导
### 方法1:使用Windows PE/恢复环境
#### 步骤1:进入Windows PE或恢复环境
1. **使用Windows安装U盘启动**
- 插入Windows安装U盘
- 从U盘启动
- 选择"修复计算机" → "疑难解答" → "命令提示符"
2. **或使用Windows恢复环境(WinRE)**
- 开机时按F8或Shift+F8
- 或从高级启动选项进入
#### 步骤2:检查磁盘状态
```cmd
# 打开磁盘管理工具
diskpart
# 列出所有磁盘
list disk
# 选择目标磁盘(例如磁盘0)
select disk 0
# 查看分区信息
list partition
```
#### 步骤3:重建GPT分区表
**选项A:使用diskpart重建GPT**
```cmd
# 进入diskpart
diskpart
# 选择磁盘
select disk 0
# 转换为GPT(会清除所有分区,谨慎使用!)
convert gpt
# 重新创建分区
create partition efi size=100
format fs=fat32 label="System"
assign letter=S
create partition primary
format fs=ntfs label="Windows"
assign letter=C
```
**选项B:使用bootrec修复引导**
```cmd
# 修复主引导记录
bootrec /fixmbr
# 修复引导扇区
bootrec /fixboot
# 重建BCD(引导配置数据)
bootrec /rebuildbcd
# 扫描Windows安装
bootrec /scanos
```
#### 步骤4:修复EFI引导
```cmd
# 挂载EFI分区(假设EFI分区为S:)
mountvol S: /S
# 重建EFI引导文件
bcdboot C:\Windows /s S: /f UEFI
# 或使用完整路径
bcdboot C:\Windows /s S: /f UEFI /l zh-cn
```
---
### 方法2:使用第三方工具
#### 使用TestDisk(推荐)
1. **下载TestDisk**
- 从官网下载:https://www.cgsecurity.org/wiki/TestDisk
2. **运行TestDisk**
```cmd
testdisk
```
3. **操作步骤**
- 选择磁盘
- 选择分区表类型:Intel/PC partition
- 选择"Analyse"(分析)
- 选择"Quick Search"(快速搜索)
- 如果找到分区,选择"Write"(写入)保存分区表
#### 使用AOMEI Partition Assistant
1. 下载并安装AOMEI Partition Assistant
2. 选择"重建MBR/GPT"
3. 选择目标磁盘
4. 选择"重建GPT"
5. 应用更改
---
## 二、Linux环境重建GUID引导
### 方法1:使用gdisk(推荐)
#### 步骤1:安装gdisk
```bash
# Ubuntu/Debian
sudo apt-get install gdisk
# CentOS/RHEL
sudo yum install gdisk
```
#### 步骤2:检查磁盘状态
```bash
# 查看磁盘信息
sudo gdisk -l /dev/sda
# 或使用fdisk
sudo fdisk -l /dev/sda
```
#### 步骤3:修复GPT分区表
```bash
# 进入gdisk交互模式
sudo gdisk /dev/sda
# 在gdisk中执行以下命令:
# p - 打印分区表
# v - 验证分区表
# w - 写入并退出(如果验证通过)
```
#### 步骤4:如果GPT损坏严重,重建GPT
```bash
# 备份当前分区表
sudo sgdisk --backup=/tmp/disk_backup.txt /dev/sda
# 使用gdisk重建
sudo gdisk /dev/sda
# 在gdisk中:
# o - 创建新的GPT分区表(会清除现有分区)
# n - 创建新分区
# 分区号:1
# 起始扇区:2048(默认)
# 结束扇区:+512M(EFI分区)
# 类型:EF00(EFI System)
# n - 创建其他分区...
# w - 写入并退出
```
#### 步骤5:重建EFI引导
```bash
# 挂载EFI分区
sudo mkdir -p /mnt/efi
sudo mount /dev/sda1 /mnt/efi # 假设EFI分区是sda1
# 安装或重新安装grub
sudo grub-install --target=x86_64-efi --efi-directory=/mnt/efi --bootloader-id=GRUB
# 更新grub配置
sudo update-grub
# 或使用grub2
sudo grub2-install /dev/sda
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
```
---
### 方法2:使用TestDisk(Linux版)
```bash
# 安装testdisk
sudo apt-get install testdisk
# 运行testdisk
sudo testdisk
# 操作步骤:
# 1. 选择"Create"创建日志
# 2. 选择磁盘
# 3. 选择分区表类型:Intel
# 4. 选择"Analyse" → "Quick Search"
# 5. 如果找到分区,选择"Write"保存
```
---
### 方法3:使用parted重建GPT
```bash
# 进入parted
sudo parted /dev/sda
# 在parted中:
# mklabel gpt # 创建GPT分区表
# mkpart primary fat32 1MiB 513MiB # 创建EFI分区
# set 1 esp on # 设置EFI标志
# mkpart primary ext4 513MiB 100% # 创建主分区
# quit # 退出
```
---
## 三、常见场景处理
### 场景1:GPT分区表损坏但分区数据完好
**解决方案**:使用gdisk或TestDisk恢复分区表
```bash
# 使用gdisk恢复
sudo gdisk /dev/sda
# 输入:p(查看),v(验证),w(写入)
```
### 场景2:EFI系统分区丢失
**解决方案**:重新创建EFI分区并安装引导
```bash
# 1. 创建EFI分区(512MB)
sudo gdisk /dev/sda
# n(新建),1(分区号),2048(起始),+512M(大小),EF00(类型)
# 2. 格式化EFI分区
sudo mkfs.vfat -F32 /dev/sda1
# 3. 挂载并安装grub
sudo mount /dev/sda1 /mnt/efi
sudo grub-install --target=x86_64-efi --efi-directory=/mnt/efi
```
### 场景3:UEFI引导记录损坏
**Windows环境**:
```cmd
bootrec /rebuildbcd
bcdboot C:\Windows /s S: /f UEFI
```
**Linux环境**:
```bash
sudo grub-install --target=x86_64-efi --efi-directory=/mnt/efi
sudo update-grub
```
### 场景4:从MBR转换为GPT
**Windows环境**:
```cmd
# 使用diskpart(会清除数据)
diskpart
select disk 0
convert gpt
```
**Linux环境**:
```bash
# 使用gdisk转换
sudo gdisk /dev/sda
# 输入:w(写入,会自动转换)
```
---
## 四、预防措施
1. **定期备份分区表**
```bash
# Linux
sudo sgdisk --backup=/backup/gpt_backup.txt /dev/sda
# Windows
# 使用AOMEI或其他工具备份分区表
```
2. **使用UPS防止突然断电**
- 突然断电可能导致分区表损坏
3. **避免强制关机**
- 正常关机可以确保数据完整写入
4. **定期检查磁盘健康**
```bash
# Linux
sudo smartctl -a /dev/sda
# Windows
# 使用CrystalDiskInfo等工具
```
---
## 五、重要警告
⚠️ **数据安全警告**:
1. **重建GPT分区表可能会清除所有分区**
- 操作前务必备份重要数据
- 使用恢复工具前先尝试只读模式
2. **某些操作不可逆**
- `convert gpt`会清除MBR分区表
- `mklabel gpt`会清除所有分区
3. **操作前检查**
- 确认选择了正确的磁盘
- 确认备份了重要数据
- 确认理解了操作后果
---
## 六、工具推荐
### Windows工具
- **TestDisk**:免费,功能强大
- **AOMEI Partition Assistant**:图形界面,易用
- **DiskGenius**:专业分区工具
- **Windows内置工具**:diskpart, bootrec
### Linux工具
- **gdisk**:GPT专用工具,推荐
- **parted**:通用分区工具
- **TestDisk**:跨平台恢复工具
- **fdisk**:传统工具(GPT支持有限)
---
## 七、故障排查
### 问题1:gdisk显示"Invalid partition table"
**原因**:GPT分区表损坏或不存在
**解决**:
```bash
sudo gdisk /dev/sda
# 输入:o(创建新GPT),然后重新创建分区
```
### 问题2:系统无法识别EFI分区
**原因**:EFI分区类型错误或未设置ESP标志
**解决**:
```bash
sudo gdisk /dev/sda
# 选择EFI分区,输入:t(改变类型),选择EF00
# 或使用parted:set 1 esp on
```
### 问题3:UEFI无法找到引导文件
**原因**:EFI引导文件缺失或路径错误
**解决**:
- Windows:使用`bcdboot`重建
- Linux:重新安装grub到EFI分区
---
## 八、快速参考命令
### Windows快速修复
```cmd
# 进入恢复环境后
diskpart
select disk 0
list partition
# 如果EFI分区存在,退出diskpart后:
bcdboot C:\Windows /s S: /f UEFI
```
### Linux快速修复
```bash
# 检查GPT
sudo gdisk -l /dev/sda
# 修复GPT(如果只是轻微损坏)
sudo gdisk /dev/sda
# p, v, w
# 重建引导
sudo mount /dev/sda1 /mnt/efi
sudo grub-install --target=x86_64-efi --efi-directory=/mnt/efi
sudo update-grub
```
---
## 总结
重建GUID引导的关键步骤:
1. **诊断问题**:确定是GPT表损坏还是引导文件损坏
2. **备份数据**:操作前务必备份
3. **修复分区表**:使用gdisk、TestDisk等工具
4. **重建引导**:重新安装UEFI引导文件
5. **验证**:确认系统可以正常启动
记住:**数据安全第一,操作前务必备份!**

View File

@ -0,0 +1,678 @@
# TrueNAS关机与硬盘操作安全指南
## 重要说明
本文档提供TrueNAS安全关机以及硬盘安全拔出和重新插入的详细操作指南。**严格按照步骤操作,避免数据损坏或硬件故障**。
---
## 一、TrueNAS安全关机流程
### ⚠️ 关键原则
1. **永远不要强制断电**(直接拔电源或按电源键强制关机)
2. **必须使用系统关机命令**(确保所有数据写入完成)
3. **等待完全关机后再操作硬件**(确保所有硬盘停止运行)
---
### 步骤1:检查TrueNAS系统状态
**操作**:
1. 通过Web界面登录TrueNAS管理页面
2. 检查系统状态:
- 查看是否有正在运行的作业(任务、备份等)
- 查看存储池状态是否正常
- 查看是否有错误或警告
**检查清单**:
- [ ] 系统正常运行,无严重错误
- [ ] 存储池状态正常(ONLINE)
- [ ] 没有正在运行的重要任务
**如果有正在运行的任务**:
- ⚠️ 等待任务完成,或手动停止非关键任务
- ⚠️ 不要强制中断正在写入数据的任务
---
### 步骤2:通过Web界面安全关机(推荐方法)
**操作步骤**:
1. **登录TrueNAS管理界面**
- 打开浏览器,访问TrueNAS管理页面
- 例如:`http://192.168.58.20/ui/` 或 `https://192.168.58.20/ui/`
2. **进入系统设置**
- 点击左侧菜单的"System"(系统)
- 或点击右上角的设置图标
3. **执行关机操作**
- 找到"Shutdown"(关机)选项
- 点击"Shutdown"按钮
- 确认关机操作
4. **等待系统关机**
- 系统会显示关机进度
- 等待系统完全关闭
- **不要在这个过程中强制断电**
**预期结果**:
- Web界面显示系统正在关机
- 系统会安全关闭所有服务
- 所有数据写入完成
- 系统完全关闭
**检查清单**:
- [ ] 已点击关机按钮
- [ ] 系统显示正在关机
- [ ] 等待系统完全关闭(通常需要1-3分钟)
---
### 步骤3:通过SSH命令行关机(备用方法)
**如果Web界面无法访问,可以使用SSH命令行**:
**操作步骤**:
1. **通过SSH连接到TrueNAS**
```bash
ssh root@192.168.58.20
# 或使用其他IP地址
```
2. **检查系统状态**
```bash
# 查看存储池状态
zpool status PoolOne
# 查看正在运行的进程
ps aux | grep -i zfs
ps aux | grep -i samba
```
3. **执行安全关机命令**
```bash
# 方法1:使用shutdown命令(推荐)
shutdown -h now
# 方法2:使用poweroff命令
poweroff
# 方法3:使用init命令
init 0
```
4. **等待系统关机**
- SSH连接会断开
- 系统开始关机流程
- **不要强制断电**
**预期结果**:
- SSH连接断开
- 系统安全关闭所有服务
- 系统完全关闭
---
### 步骤4:确认系统完全关机
**检查方法**:
1. **检查网络连接**
- 尝试ping TrueNAS的IP地址
- 如果没有响应,说明系统已关闭
```bash
ping 192.168.58.20
# 如果显示"请求超时"或"目标主机不可达",说明已关机
```
2. **检查Web界面**
- 尝试访问TrueNAS Web界面
- 如果无法连接,说明系统已关闭
3. **检查物理指示灯**
- 查看TrueNAS服务器的电源指示灯
- 查看硬盘指示灯(应该都熄灭或停止闪烁)
- 如果可能,查看系统风扇(应该停止运行)
4. **等待额外时间**
- 即使系统看起来已关闭,也建议等待**至少30秒**
- 确保所有硬盘完全停止运行
- 确保所有数据写入完成
**检查清单**:
- [ ] 网络ping无响应
- [ ] Web界面无法访问
- [ ] 电源指示灯熄灭或显示关机状态
- [ ] 硬盘指示灯全部熄灭(不再闪烁)
- [ ] 系统风扇停止运行(如果可见)
- [ ] 已等待至少30秒
---
### ⚠️ 重要提醒
1. **不要使用强制关机**
- ❌ 不要直接拔电源线
- ❌ 不要长按电源键强制关机
- ❌ 不要按Reset键重启
- ✅ 必须使用系统关机命令
2. **如果系统无法正常关机**
- 尝试SSH命令行关机
- 如果SSH也无法访问,等待至少5-10分钟
- 只有在确认系统完全无响应后,才考虑强制断电(**最后手段**)
3. **关机时间**
- 正常关机通常需要1-3分钟
- 如果系统有大量数据写入,可能需要更长时间
- 耐心等待,不要着急
---
## 二、硬盘安全拔出操作
### ⚠️ 关键原则
1. **确保系统完全关机后再操作**
2. **按顺序操作,避免混淆**
3. **标记每块硬盘,避免混淆**
4. **小心操作,避免物理损坏**
---
### 步骤1:准备工具和材料
**所需工具**:
- [ ] 螺丝刀(用于打开TrueNAS机箱)
- [ ] 标签纸或贴纸(用于标记硬盘)
- [ ] 笔或标记笔(用于写标签)
- [ ] 防静电措施(如果可能,使用防静电手环或触摸金属物体释放静电)
**准备标签**:
- 准备7张标签纸
- 标记为:硬盘1、硬盘2、硬盘3、硬盘4、硬盘5、硬盘6、硬盘7
- 或使用TrueNAS中的硬盘标识(例如:ata-WDC_WD80EFBX-68AZZN0_VRGYN7GK)
---
### 步骤2:打开TrueNAS机箱
**操作步骤**:
1. **确认系统完全关机**
- 按照"一、TrueNAS安全关机流程"完成关机
- 确认系统完全关闭(等待至少30秒)
2. **断开电源**
- 拔掉TrueNAS服务器的电源线
- 确保完全断电
3. **打开机箱**
- 使用螺丝刀打开TrueNAS机箱侧板
- 注意:不同机箱打开方式不同
- 参考机箱说明书或小心操作
4. **触摸金属物体释放静电**
- 在操作硬盘前,触摸机箱金属部分
- 或使用防静电手环
**检查清单**:
- [ ] 系统完全关机
- [ ] 电源线已拔掉
- [ ] 机箱已打开
- [ ] 已释放静电
---
### 步骤3:识别和标记硬盘
**操作步骤**:
1. **查看硬盘位置**
- 观察TrueNAS机箱内的硬盘布局
- 记录每块硬盘的位置(例如:从上到下、从左到右)
2. **识别硬盘**
- TrueNAS中的硬盘通常有标识(例如:硬盘槽位编号)
- 查看硬盘上的标签(例如:WDC WD80EFBX序列号)
3. **标记硬盘**
- 在每块硬盘上贴上标签纸
- 标记硬盘编号(1-7)
- 建议标记方式:
- 硬盘1、硬盘2、...、硬盘7
- 或使用TrueNAS中的标识(如果知道)
**重要提醒**:
- ⚠️ **标记非常重要**,避免混淆硬盘顺序
- ⚠️ raidz2配置对硬盘顺序敏感(虽然ZFS通常可以自动识别,但标记有助于后续操作)
- ⚠️ 如果可能,拍照记录硬盘位置和标记
**检查清单**:
- [ ] 已查看所有硬盘位置
- [ ] 已标记所有7块硬盘
- [ ] 标签清晰可见
- [ ] 已拍照记录(如果可能)
---
### 步骤4:拔出SATA数据线
**操作步骤**:
1. **找到SATA数据线**
- 每块硬盘有两条线:
- SATA数据线(较小的L形接口,通常红色或黑色)
- SATA电源线(较大的L形接口,通常黄色或黑色,有多个针脚)
2. **拔出SATA数据线**
- 轻轻握住SATA数据线的接口部分
- 不要拉线本身,拉接口部分
- 轻轻向外拉出(通常有卡扣,需要稍微用力)
- **注意**:不要用力过猛,避免损坏接口
3. **拔出所有7块硬盘的SATA数据线**
- 依次拔出所有硬盘的SATA数据线
- 每拔出一根,确认已完全拔出
**注意事项**:
- ⚠️ SATA数据线有方向性,但拔出时通常不需要考虑方向
- ⚠️ 如果卡扣很紧,可以轻轻左右晃动后再拔出
- ⚠️ 不要用力过猛,避免损坏接口
**检查清单**:
- [ ] 所有7块硬盘的SATA数据线都已拔出
- [ ] 数据线接口完好,没有损坏
---
### 步骤5:拔出SATA电源线
**操作步骤**:
1. **找到SATA电源线**
- SATA电源线通常来自电源模块
- 接口较大,有多个针脚(15针)
- 通常一条电源线有多个接口(可以连接多块硬盘)
2. **拔出SATA电源线**
- 轻轻握住SATA电源线的接口部分
- 不要拉线本身,拉接口部分
- 轻轻向外拉出
- **注意**:SATA电源线通常有卡扣,需要稍微用力
3. **拔出所有7块硬盘的SATA电源线**
- 依次拔出所有硬盘的SATA电源线
- 每拔出一根,确认已完全拔出
**注意事项**:
- ⚠️ SATA电源线通常比数据线更紧
- ⚠️ 如果卡扣很紧,可以轻轻左右晃动后再拔出
- ⚠️ 不要用力过猛,避免损坏接口或电源线
**检查清单**:
- [ ] 所有7块硬盘的SATA电源线都已拔出
- [ ] 电源线接口完好,没有损坏
---
### 步骤6:移除硬盘
**操作步骤**:
1. **松开硬盘固定**
- 大多数TrueNAS系统使用硬盘托盘或固定架
- 找到固定螺丝或卡扣
- 松开固定装置
2. **取出硬盘**
- 轻轻握住硬盘两侧(不要触摸电路板)
- 沿着硬盘槽位方向轻轻拉出
- 如果卡得很紧,可以左右轻轻晃动后再拉出
3. **移除所有7块硬盘**
- 依次移除所有7块硬盘
- 每取出一块,确认标记清晰可见
- 将硬盘放在安全的位置(防静电、防震)
**注意事项**:
- ⚠️ **不要触摸硬盘的电路板**(底部的绿色电路板)
- ⚠️ **避免硬盘受到震动或冲击**
- ⚠️ 将硬盘放在防静电的地方(例如:防静电包装袋、泡沫垫等)
- ⚠️ 避免硬盘叠放(如果叠放,中间要有缓冲材料)
**检查清单**:
- [ ] 所有7块硬盘都已移除
- [ ] 硬盘标记清晰可见
- [ ] 硬盘放在安全位置(防静电、防震)
- [ ] 硬盘没有物理损坏
---
## 三、硬盘安全重新插入操作
### ⚠️ 关键原则
1. **按照标记顺序操作**
2. **小心操作,避免物理损坏**
3. **确保连接牢固**
4. **恢复完成后,按照相同步骤重新安装到TrueNAS**
---
### 步骤1:准备重新插入
**操作步骤**:
1. **确认Windows电脑已关闭**
- 关闭Windows电脑
- 断开电源
- 等待至少30秒
2. **打开Windows电脑机箱**
- 使用螺丝刀打开机箱侧板
- 确认有足够的空间安装5块硬盘
3. **准备连接**
- 准备好5根SATA数据线
- 检查电源是否有足够的SATA电源接口
- 如果电源接口不足,准备SATA电源分线器
**检查清单**:
- [ ] Windows电脑已关闭并断电
- [ ] 机箱已打开
- [ ] 有足够的空间安装硬盘
- [ ] SATA数据线已准备好
- [ ] 电源接口已检查
---
### 步骤2:安装5块硬盘到Windows电脑(SATA连接)
**操作步骤**:
1. **选择5块硬盘**
- 从7块硬盘中选择5块(任意5块都可以,因为raidz2不依赖硬盘顺序)
- 但建议选择标记清晰的5块,方便后续操作
2. **安装硬盘到机箱**
- 如果机箱有硬盘位,将硬盘安装到硬盘位
- 使用螺丝固定硬盘
- 如果机箱没有硬盘位,将硬盘放在安全的位置(注意防震)
3. **连接SATA数据线**
- 将SATA数据线的一端连接到主板上的SATA接口
- 将SATA数据线的另一端连接到硬盘的SATA数据接口
- 确保连接牢固(听到"咔哒"声表示连接到位)
4. **连接SATA电源线**
- 从电源找到SATA电源线
- 将SATA电源线连接到硬盘的SATA电源接口
- 确保连接牢固
5. **重复操作**
- 对剩余4块硬盘重复步骤3和4
- 确保所有5块硬盘都已连接
**检查清单**:
- [ ] 5块硬盘已安装到机箱
- [ ] 所有SATA数据线已连接到主板和硬盘
- [ ] 所有SATA电源线已连接到硬盘
- [ ] 所有连接牢固
---
### 步骤3:安装2块硬盘到USB硬盘盒
**操作步骤**:
1. **选择2块硬盘**
- 从剩余的2块硬盘中选择(已标记的2块)
2. **打开USB硬盘盒**
- 按照USB硬盘盒的说明书打开硬盘盒
- 注意:不同硬盘盒打开方式不同
3. **安装硬盘到USB硬盘盒**
- 将硬盘放入USB硬盘盒
- 按照硬盘盒说明书固定硬盘
- 关闭USB硬盘盒
4. **重复操作**
- 对另一块硬盘重复步骤2和3
**检查清单**:
- [ ] 2块硬盘已安装到USB硬盘盒
- [ ] USB硬盘盒已正确关闭
- [ ] 硬盘标记清晰可见(如果可能)
---
### 步骤4:连接USB硬盘盒到Windows电脑
**操作步骤**:
1. **连接USB线**
- 将USB线连接到USB硬盘盒
- 将USB线连接到Windows电脑的USB 3.0接口(通常是蓝色的)
2. **连接电源适配器**
- 将USB硬盘盒的电源适配器连接到电源插座
- 将电源适配器的DC线连接到USB硬盘盒
- 确认电源适配器正常工作(通常有指示灯)
3. **重复操作**
- 对另一个USB硬盘盒重复步骤1和2
**检查清单**:
- [ ] 2个USB硬盘盒都已连接到电脑
- [ ] USB线已连接到USB 3.0接口
- [ ] 电源适配器已连接并正常工作
---
### 步骤5:启动Windows电脑并检查连接
**操作步骤**:
1. **关闭机箱**
- 确认所有连接正确
- 关闭Windows电脑机箱侧板
2. **启动Windows电脑**
- 连接电源
- 启动Windows电脑
- 等待系统完全启动
3. **检查设备管理器**
- 右键点击"此电脑" → "属性" → "设备管理器"
- 或:Win + X → "设备管理器"
- 展开"磁盘驱动器"
- 应该能看到所有7块硬盘(5块SATA + 2块USB)
4. **检查磁盘管理**
- Win + X → "磁盘管理"
- 应该能看到所有7块硬盘
- 硬盘可能显示为"未初始化"或"未分配"(这是正常的,因为ZFS文件系统Windows不识别)
**检查清单**:
- [ ] Windows电脑已启动
- [ ] 设备管理器中能看到所有7块硬盘
- [ ] 磁盘管理中能看到所有7块硬盘
- [ ] 没有错误或警告信息
---
## 四、数据恢复完成后的硬盘重新安装
### 步骤1:安全关闭Windows电脑
**操作步骤**:
1. **关闭UFS Explorer软件**
- 确保数据恢复已完成
- 关闭UFS Explorer软件
2. **安全关闭Windows电脑**
- 使用Windows正常关机流程
- 等待系统完全关闭
3. **断开所有连接**
- 断开USB硬盘盒的USB线和电源
- 断开SATA数据线和电源线
- 断开Windows电脑电源
---
### 步骤2:从Windows电脑移除硬盘
**操作步骤**:
1. **移除5块SATA硬盘**
- 按照"二、硬盘安全拔出操作"的步骤移除5块SATA硬盘
- 注意保持标记清晰
2. **从USB硬盘盒移除2块硬盘**
- 打开USB硬盘盒
- 移除硬盘
- 注意保持标记清晰
---
### 步骤3:重新安装到TrueNAS
**操作步骤**:
1. **打开TrueNAS机箱**
- 确保TrueNAS系统完全关闭
- 打开机箱
2. **按照标记顺序安装硬盘**
- 按照之前的标记顺序安装所有7块硬盘
- 连接SATA数据线和电源线
- 固定硬盘
3. **检查连接**
- 确认所有连接正确
- 关闭机箱
4. **启动TrueNAS**
- 连接电源
- 启动TrueNAS系统
- 检查存储池状态是否正常
---
## 五、常见问题和故障排查
### 问题1:TrueNAS无法正常关机
**症状**:
- 点击关机按钮后系统无响应
- SSH连接断开但系统仍在运行
**解决方法**:
1. 等待至少5-10分钟
2. 尝试SSH连接,查看系统状态
3. 如果确认系统完全无响应,考虑强制断电(**最后手段**)
4. 强制断电后,重新启动系统,检查存储池状态
### 问题2:硬盘无法拔出
**症状**:
- SATA数据线或电源线卡得很紧
- 硬盘固定在槽位中无法取出
**解决方法**:
1. **SATA线无法拔出**:
- 轻轻左右晃动接口
- 检查是否有卡扣需要按下
- 如果仍然无法拔出,不要用力过猛,考虑使用工具小心操作
2. **硬盘无法取出**:
- 检查固定螺丝是否已完全松开
- 检查是否有卡扣需要按下
- 轻轻左右晃动硬盘
- 如果仍然无法取出,参考机箱或TrueNAS系统说明书
### 问题3:硬盘标记丢失或混淆
**症状**:
- 标签纸掉落
- 无法确定硬盘顺序
**解决方法**:
1. **如果标签丢失**:
- 查看硬盘上的序列号(通常在硬盘标签上)
- 参考TrueNAS系统中的硬盘标识
- 重新标记硬盘
2. **如果完全混淆**:
- ZFS通常可以自动识别硬盘(通过UUID或序列号)
- 但如果可能,尽量按照原始顺序安装
- 重新安装后,检查TrueNAS系统,确认存储池状态正常
### 问题4:Windows电脑无法识别硬盘
**症状**:
- 设备管理器中看不到所有7块硬盘
- 磁盘管理中看不到所有硬盘
**解决方法**:
1. **检查SATA连接**:
- 确认SATA数据线连接牢固
- 确认SATA电源线连接牢固
- 尝试重新连接
2. **检查USB连接**:
- 确认USB线连接牢固
- 确认电源适配器正常工作
- 尝试不同的USB接口
3. **检查BIOS设置**:
- 进入BIOS,检查SATA接口是否启用
- 检查USB接口是否启用
4. **检查设备管理器**:
- 查看是否有未知设备或错误设备
- 尝试刷新设备列表
---
## 六、重要提醒总结
### ⚠️ TrueNAS关机
1. ✅ **必须使用系统关机命令**(Web界面或SSH)
2. ✅ **等待系统完全关闭**(至少30秒)
3. ❌ **不要强制断电**(除非系统完全无响应)
### ⚠️ 硬盘操作
1. ✅ **标记所有硬盘**(避免混淆)
2. ✅ **小心操作,避免物理损坏**
3. ✅ **避免触摸硬盘电路板**
4. ✅ **避免硬盘受到震动或冲击**
5. ✅ **将硬盘放在防静电的地方**
### ⚠️ 连接操作
1. ✅ **确保连接牢固**
2. ✅ **按照正确方向插入接口**
3. ✅ **不要用力过猛,避免损坏接口**
### ⚠️ 数据恢复
1. ✅ **在数据恢复过程中,不要移动硬盘**
2. ✅ **保持系统稳定运行**
3. ✅ **不要中断恢复过程**
---
**文档创建时间**:2026-01-13
**适用场景**:TrueNAS ZFS存储池硬盘移除和重新安装
**操作对象**:7块8TB硬盘(WDC WD80EFBX)

View File

@ -0,0 +1,210 @@
# UFS Explorer Professional Recovery 数据恢复操作指南
## 重要说明
本文档用于指导使用UFS Explorer Professional Recovery恢复TrueNAS ZFS存储池中的数据。
**恢复目标**:`/mnt/PoolOne/apps/youyidian-3d/uploads` 目录(约1.5TB数据)
---
## 准备工作
### 硬件要求
- ✅ 所有7块TrueNAS硬盘(raidz2配置需要全部硬盘)
- ✅ 足够的SATA接口(7个)或USB 3.0硬盘盒
- ✅ 足够的电源供应(7块8TB硬盘)
- ✅ **至少2TB可用空间**(用于存储恢复的文件)
### 软件要求
- ✅ UFS Explorer Professional Recovery 10.11.1(已安装)
- ✅ Windows系统
---
## 操作步骤
### 第一步:连接硬盘
1. **连接所有7块硬盘到Windows电脑**
- 选项A:直接SATA连接(需要足够接口)
- 选项B:USB 3.0硬盘盒(推荐,方便)
2. **确保所有硬盘都已识别**
- 打开"设备管理器",查看"磁盘驱动器"
- 应该能看到所有7块硬盘
### 第二步:启动UFS Explorer
1. **运行程序**
- 打开:`UFSExplorerPortable.exe`
2. **软件界面说明**
- 左侧:显示"Logical disks"和"Connected storages"
- 右侧:软件设置
### 第三步:识别ZFS存储池
1. **查看连接的存储设备**
- 在左侧"Connected storages"部分
- 应该能看到所有7块硬盘
2. **识别ZFS存储池**
- UFS Explorer应该能够自动识别ZFS文件系统
- 查找名为"PoolOne"的存储池
- 如果看到"ZFS"或"raidz2"标识,说明识别成功
3. **如果无法自动识别**
- 尝试手动扫描:Tools → Scan for lost data
- 选择所有7块硬盘进行扫描
### 第四步:浏览和恢复文件
1. **打开ZFS存储池**
- 双击"PoolOne"存储池
- 浏览目录结构,找到:`apps/youyidian-3d/uploads/`
2. **选择要恢复的文件**
- 进入uploads目录
- 选择所有文件(Ctrl+A)
- 或选择特定文件
3. **开始恢复**
- 右键点击选择的文件/文件夹
- 选择"Copy"或"Save files"
- 选择保存位置(需要至少2TB空间)
- 点击"OK"开始恢复
### 第五步:等待恢复完成
- 恢复1.5TB数据需要较长时间
- 保持电脑运行,不要中断
- 可以在软件中查看进度
---
## 关键界面翻译(常用功能)
### 菜单栏(Menu Bar)
- **Open** - 打开
- **Refresh** - 刷新
- **RAID** - RAID配置
- **Tools** - 工具
- **Disk image** - 磁盘镜像
- **About** - 关于
### 右键菜单(Right-click Menu)
- **Copy** / **Save files** - 复制/保存文件
- **Open** - 打开
- **Properties** - 属性
- **Scan for lost data** - 扫描丢失的数据
### 软件设置(Software Settings)
- **Path for temporary files** - 临时文件路径
- **Language** - 语言
- **Default file open action** - 默认文件打开操作
- **Duplicate file conflict action** - 重复文件冲突操作
---
## 注意事项
### ⚠️ 重要提醒
1. **必须连接所有7块硬盘**
- raidz2配置需要全部硬盘同时访问
- 缺少任何一块都无法完整恢复
2. **使用只读模式**
- 建议在设置中启用只读模式
- 避免意外写入导致数据损坏
3. **保存位置选择**
- 选择有足够空间的位置(至少2TB)
- 建议使用NTFS文件系统
- 避免保存到系统盘(C盘)
4. **恢复时间**
- 1.5TB数据恢复需要较长时间
- 根据硬盘速度,可能需要数小时到数十小时
- 保持电脑和软件运行,不要中断
5. **数据验证**
- 恢复完成后,检查文件完整性
- 对比文件大小和数量
- 尝试打开部分文件验证
---
## 故障排查
### 问题1:无法识别ZFS存储池
**解决方法**:
- 确认所有7块硬盘都已连接
- 尝试Tools → Scan for lost data
- 检查硬盘是否有物理损坏
### 问题2:恢复速度很慢
**可能原因**:
- USB连接速度限制(使用USB 3.0)
- 硬盘有坏道
- 数据碎片化严重
**解决方法**:
- 使用SATA直接连接(如果可能)
- 耐心等待,不要中断
### 问题3:恢复的文件损坏
**可能原因**:
- 数据已被覆盖
- ZFS删除机制导致数据不可恢复
- 硬盘有物理损坏
**解决方法**:
- 检查恢复的文件,尝试修复
- 考虑使用专业数据恢复服务
---
## 成功标准
恢复完成后,检查:
- ✅ uploads目录中的所有文件都已恢复
- ✅ 文件大小和数量与原数据一致(或接近)
- ✅ 文件可以正常打开和使用
- ✅ 文件完整性验证通过
---
## 后续步骤
恢复完成后:
1. **备份恢复的数据**
- 将恢复的文件备份到安全位置
- 建议使用外部硬盘或NAS
2. **验证数据完整性**
- 检查文件是否可以正常使用
- 对比文件列表和大小
3. **恢复数据到TrueNAS**
- 将恢复的文件重新上传到TrueNAS
- 或通过其他方式传输到服务器
---
## 技术支持
如果遇到问题:
1. 查看软件帮助文档
2. 联系UFS Explorer官方支持
3. 考虑专业数据恢复服务
---
**文档创建时间**:2026-01-13
**适用版本**:UFS Explorer Professional Recovery 10.11.1
**恢复目标**:TrueNAS ZFS存储池 PoolOne/apps/youyidian-3d/uploads

View File

@ -0,0 +1,196 @@
# UFS Explorer Professional Recovery 界面翻译对照表
## 说明
本文档提供UFS Explorer Professional Recovery的常用界面元素中文翻译,方便在数据恢复过程中使用。
---
## 主界面(Main Interface)
### 菜单栏(Menu Bar)
| 英文 | 中文 |
|------|------|
| Open | 打开 |
| Refresh | 刷新 |
| RAID | RAID配置 |
| Tools | 工具 |
| Disk image | 磁盘镜像 |
| About | 关于 |
### 左侧面板(Left Panel)
| 英文 | 中文 |
|------|------|
| Logical disks | 逻辑磁盘 |
| Connected storages | 已连接的存储设备 |
| Fixed | 固定磁盘 |
| Removable | 可移动磁盘 |
| NTFS | NTFS文件系统 |
| FAT32 | FAT32文件系统 |
| ZFS | ZFS文件系统 |
| Total size | 总大小 |
| Free space | 可用空间 |
### 右键菜单(Right-click Menu)
| 英文 | 中文 |
|------|------|
| Open | 打开 |
| Copy | 复制 |
| Save files | 保存文件 |
| Properties | 属性 |
| Scan for lost data | 扫描丢失的数据 |
| Create disk image | 创建磁盘镜像 |
| View in HEX editor | 在十六进制编辑器中查看 |
---
## 软件设置(Software Settings)
### 常规设置(General Settings)
| 英文 | 中文 |
|------|------|
| Path for temporary files | 临时文件路径 |
| Language | 语言 |
| Zoom user interface | 缩放用户界面 |
| UI color theme | UI颜色主题 |
| Process (include) Windows logical drives | 处理(包括)Windows逻辑驱动器 |
| Recognize (display) connected media devices | 识别(显示)连接的媒体设备 |
| Default FS policy for defective blocks | 默认文件系统策略(对于坏块) |
| System memory size for file cache (GB) | 文件缓存系统内存大小(GB) |
### 文件浏览器(Explorer/File Browser)
| 英文 | 中文 |
|------|------|
| Maximum records per page | 每页最大记录数 |
| Maximum search results | 最大搜索结果 |
| Default file open action | 默认文件打开操作 |
| Display bad objects | 显示损坏的对象 |
| Display symbolic links | 显示符号链接 |
| Enable file status (validity) indication by default | 默认启用文件状态(有效性)指示 |
### 文件复制(Files Copying)
| 英文 | 中文 |
|------|------|
| Duplicate file conflict action | 重复文件冲突操作 |
| Display a progress of the entire copying job | 显示整个复制作业的进度 |
| Log conflicts | 记录冲突 |
| Write a summary of copying to the log | 将复制摘要写入日志 |
| When an object cannot be created | 当无法创建对象时 |
| When a read error occurs | 当发生读取错误时 |
| Metadata filtering | 元数据过滤 |
### 文件操作选项(File Operation Options)
| 英文 | 中文 |
|------|------|
| Ask what to do | 询问如何处理 |
| Skip | 跳过 |
| Overwrite | 覆盖 |
| Rename | 重命名 |
| Abort file copying with an error | 中止文件复制并报错 |
| Retry | 重试 |
| Ignore | 忽略 |
---
## 对话框(Dialogs)
### 扫描对话框(Scan Dialog)
| 英文 | 中文 |
|------|------|
| Scan for lost data | 扫描丢失的数据 |
| Select partition | 选择分区 |
| Select disk | 选择磁盘 |
| Scan | 扫描 |
| Cancel | 取消 |
| Progress | 进度 |
| Estimated time remaining | 预计剩余时间 |
### 保存文件对话框(Save Files Dialog)
| 英文 | 中文 |
|------|------|
| Select destination folder | 选择目标文件夹 |
| Save | 保存 |
| Cancel | 取消 |
| Browse | 浏览 |
| Create subfolder | 创建子文件夹 |
| Preserve folder structure | 保留文件夹结构 |
### 进度对话框(Progress Dialog)
| 英文 | 中文 |
|------|------|
| Copying files | 正在复制文件 |
| Progress | 进度 |
| Files processed | 已处理文件数 |
| Total files | 总文件数 |
| Data processed | 已处理数据 |
| Total data | 总数据 |
| Speed | 速度 |
| Time elapsed | 已用时间 |
| Time remaining | 剩余时间 |
| Pause | 暂停 |
| Cancel | 取消 |
---
## 状态和消息(Status and Messages)
### 状态信息(Status Information)
| 英文 | 中文 |
|------|------|
| Online | 在线 |
| Offline | 离线 |
| Healthy | 健康 |
| Degraded | 降级 |
| Faulted | 故障 |
| Unavailable | 不可用 |
| Read-only | 只读 |
### 错误消息(Error Messages)
| 英文 | 中文 |
|------|------|
| Error reading disk | 读取磁盘错误 |
| Access denied | 访问被拒绝 |
| File not found | 文件未找到 |
| Disk is full | 磁盘已满 |
| Cannot create file | 无法创建文件 |
| Operation cancelled | 操作已取消 |
---
## 常用操作流程翻译
### 恢复文件流程
1. **打开软件** → Open software
2. **识别存储设备** → Identify storage devices
3. **扫描丢失的数据** → Scan for lost data
4. **选择要恢复的文件** → Select files to recover
5. **保存文件** → Save files
6. **选择保存位置** → Select destination
7. **开始恢复** → Start recovery
8. **等待完成** → Wait for completion
---
## 使用建议
1. **打印或保存此文档**:在恢复过程中随时参考
2. **使用浏览器翻译**:如果需要更详细的翻译,可以使用浏览器翻译功能
3. **截图对比**:如果遇到不熟悉的界面,可以截图后对照此文档
---
**文档创建时间**:2026-01-13
**适用版本**:UFS Explorer Professional Recovery 10.11.1

View File

@ -0,0 +1,982 @@
# USB硬盘盒数据恢复详细计划
## 重要说明
本文档基于用户硬件配置(2个USB硬盘盒 + 5个SATA接口),制定详细的数据恢复执行计划。
**恢复目标**:`/mnt/PoolOne/apps/youyidian-3d/uploads` 目录(约1.5TB数据)
**硬件配置**:TrueNAS ZFS存储池,raidz2配置,7块8TB硬盘(WDC WD80EFBX)
**用户硬件**:2个USB硬盘盒 + 5个SATA接口 + Windows系统 + UFS Explorer Professional Recovery
**推荐方案**:✅ **混合连接(2块USB + 5块SATA)** - 完全可行,成本最低,速度最快!
---
## 一、硬件连接方案分析
### 关键问题:2个USB硬盘盒 vs 7块硬盘
**核心限制**:
- ✅ raidz2配置需要**所有7块硬盘同时连接**
- ⚠️ 只有2个USB硬盘盒 = 最多只能连接2块硬盘(假设是单盘硬盘盒)
- ❌ **缺少5块硬盘无法完整恢复数据**
### USB硬盘盒类型确认
需要确认你购买的USB硬盘盒类型:
#### 情况1:单盘USB硬盘盒(每个硬盘盒只能装1块硬盘)
- **连接能力**:2块硬盘
- **问题**:缺少5块硬盘,**无法完整恢复raidz2数据**
- **解决方案**:见下方"替代方案"
#### 情况2:多盘USB硬盘盒(每个硬盘盒可装多块硬盘)
- **连接能力**:取决于硬盘盒规格(常见有2盘、4盘、5盘等)
- **如果总容量 ≥ 7块**:✅ 可以完整恢复
- **如果总容量 < 7块**:❌ 无法完整恢复
#### 情况3:USB扩展坞/SATA转USB转接卡
- **连接能力**:可以连接多个硬盘(通过USB Hub或PCIe转USB)
- **可行性**:✅ 可以完整恢复(如果接口足够)
---
## 二、可行性评估(基于用户硬件配置)
### ✅ 用户实际硬件配置
- **USB硬盘盒**:2个(单盘或多盘)
- **Windows电脑SATA接口**:5个可用
- **总连接能力**:2块(USB)+ 5块(SATA)= **7块硬盘** ✅
### 🎯 推荐方案:混合连接(USB + SATA)
**可行性**:✅ **完全可行!**
**连接配置**:
- **2块硬盘**:通过USB硬盘盒连接
- **5块硬盘**:通过SATA直连
**优势**:
- ✅ **无需购买额外硬件**(利用现有的SATA接口)
- ✅ **成本最低**(只需要SATA数据线和电源线)
- ✅ **速度更快**(SATA连接比USB更快、更稳定)
- ✅ **稳定性更好**(SATA连接更可靠)
- ✅ **满足raidz2要求**(所有7块硬盘同时连接)
**要求**:
- ✅ Windows电脑有5个SATA接口(已确认有)
- ✅ 需要SATA数据线(5根)
- ✅ 需要SATA电源线(检查电源是否有多余接口)
- ✅ 需要打开电脑机箱
### ⚠️ 其他方案(仅供参考)
**场景A:如果只使用USB硬盘盒**
- **可行性**:❌ **不可行**(只有2个USB硬盘盒,无法连接7块硬盘)
- **结论**:需要购买至少5个额外的USB硬盘盒
**场景B:如果USB硬盘盒是多盘的(总容量≥7块)**
- **可行性**:✅ **可行**
- **但**:混合连接方案更好(速度更快、成本更低)
---
## 三、最终推荐方案:混合连接(USB + SATA)⭐⭐⭐⭐⭐
### ✅ 基于用户硬件配置的最佳方案
**用户硬件**:
- ✅ 2个USB硬盘盒
- ✅ 5个可用SATA接口
- ✅ 总计可连接7块硬盘(满足raidz2要求)
### 连接配置
**方案设计**:
- **2块硬盘**:通过USB硬盘盒连接(使用现有的USB硬盘盒)
- **5块硬盘**:通过SATA直连(使用电脑的5个SATA接口)
### 优势分析
1. **成本最低** ⭐⭐⭐⭐⭐
- 无需购买额外硬件
- 只需要SATA数据线(约10-20元/根,5根约50-100元)
- 总成本:约50-100元
2. **速度最快** ⭐⭐⭐⭐⭐
- SATA 3.0速度:6 Gbps(约600 MB/s)
- USB 3.0速度:5 Gbps(约500 MB/s)
- 混合连接速度:约500-600 MB/s
- 预计恢复时间:约1-2小时(1.5TB数据)
3. **稳定性最好** ⭐⭐⭐⭐⭐
- SATA连接比USB连接更稳定
- 不容易出现连接中断
- 适合长时间数据恢复操作
4. **满足要求** ⭐⭐⭐⭐⭐
- 所有7块硬盘同时连接
- 满足raidz2配置要求
- 可以完整恢复数据
### 所需硬件清单
**已有硬件**:
- ✅ 2个USB硬盘盒
- ✅ 所有7块TrueNAS硬盘
- ✅ Windows电脑(有5个SATA接口)
**需要购买的硬件**:
- ⚠️ **SATA数据线**:5根(约10-20元/根,总计50-100元)
- ⚠️ **SATA电源线**:检查电源是否有足够的SATA电源接口(通常SATA电源线有多个接口,可能不需要额外购买)
**可选硬件**(如果需要):
- 硬盘固定架或螺丝(如果机箱有硬盘位)
- 或使用硬盘转接架(如果需要)
### 注意事项
1. **打开电脑机箱**
- 需要打开电脑机箱进行连接
- 操作前确保电脑已关闭并断电
2. **电源供应**
- 7块8TB硬盘总功耗约70-100W
- 检查电源是否有足够的SATA电源接口
- 如果电源接口不足,可能需要SATA电源分线器(约20-50元)
3. **SATA接口位置**
- 确认5个SATA接口的位置(通常在主板上)
- 确认接口类型(SATA 3.0或SATA 2.0,都可以使用)
4. **硬盘固定**
- 如果机箱有硬盘位,建议固定硬盘
- 如果机箱没有硬盘位,可以放在安全的位置(注意防震)
5. **散热考虑**
- 7块硬盘同时运行会产生热量
- 确保有良好的通风
- 监控硬盘温度
---
## 四、USB连接的限制和注意事项
### USB速度限制
| USB版本 | 理论速度 | 实际速度 | 1.5TB数据恢复时间(估算) |
|---------|---------|---------|-------------------------|
| USB 2.0 | 480 Mbps (60 MB/s) | 约30-40 MB/s | **约10-14小时** |
| USB 3.0 | 5 Gbps (625 MB/s) | 约100-150 MB/s | **约3-4小时** |
| USB 3.1 | 10 Gbps (1.25 GB/s) | 约200-300 MB/s | **约1.5-2小时** |
**建议**:
- ✅ 使用USB 3.0或更高速度
- ❌ 避免使用USB 2.0(速度太慢)
### USB稳定性问题
**潜在问题**:
- ⚠️ USB连接可能不稳定(特别是长时间运行)
- ⚠️ 多块USB硬盘同时连接可能导致USB Hub过载
- ⚠️ 电源供应不足可能导致连接中断
**解决方案**:
- ✅ 使用高质量的USB 3.0 Hub(如果有多个USB设备)
- ✅ 每个USB硬盘盒使用独立电源适配器
- ✅ 避免在恢复过程中移动USB设备
- ✅ 定期检查连接状态
### USB接口数量限制
**问题**:
- 大多数电脑有4-6个USB接口
- 如果需要连接7个USB硬盘盒,可能需要USB Hub
**解决方案**:
- ✅ 使用USB 3.0 Hub(至少7个端口)
- ✅ 确保Hub有独立电源供应
- ✅ 建议使用有源USB Hub(带电源适配器)
---
## 五、详细执行计划
### 阶段1:硬件准备和连接
#### 步骤1.1:确认USB硬盘盒类型
**操作**:
1. 检查USB硬盘盒包装或说明书
2. 确认每个硬盘盒可以连接多少块硬盘
3. 计算总连接能力
**检查清单**:
- [ ] USB硬盘盒类型:单盘 / 多盘(____盘)
- [ ] 总连接能力:____块硬盘
- [ ] USB版本:USB 2.0 / USB 3.0 / USB 3.1
#### 步骤1.2:评估连接方案
**根据步骤1.1的结果**:
**如果总连接能力 ≥ 7块硬盘**:
- ✅ 可以完整恢复,继续执行步骤1.3
**如果总连接能力 < 7块硬盘**:
- ❌ 需要购买更多USB硬盘盒
- 或使用混合连接方案(USB + SATA)
- 或购买多盘USB硬盘盒
#### 步骤1.3:准备所有硬件
**所需硬件清单**:
- [ ] 2个USB硬盘盒(已购买)
- [ ] 如果不够,准备额外的USB硬盘盒或SATA连接方案
- [ ] 所有7块TrueNAS硬盘
- [ ] USB 3.0 Hub(如果需要)
- [ ] 足够的电源适配器(每个USB硬盘盒一个)
- [ ] 足够的电源插座
- [ ] 如果使用SATA连接:SATA数据线、电源线、PCIe转SATA卡(如果需要)
#### 步骤1.4:连接所有硬盘(混合连接方案)
**操作步骤**:
1. **关闭TrueNAS系统**
- 安全关闭TrueNAS服务器
- 等待所有硬盘停止运行(约30秒)
2. **关闭Windows电脑**
- 完全关闭Windows电脑
- 断开电源
- 等待30秒确保完全断电
3. **移除硬盘**
- 从TrueNAS服务器移除所有7块硬盘
- 标记每块硬盘(记录硬盘序号,避免混淆)
- 建议:用标签纸标记硬盘编号(1-7)
4. **准备SATA连接(5块硬盘)**
**步骤4.1:打开Windows电脑机箱**
- 使用螺丝刀打开机箱侧板
- 注意:不同机箱打开方式不同,查看机箱说明书
**步骤4.2:连接SATA数据线**
- 找到主板上的5个SATA接口
- SATA接口通常是L形的,有多个接口(通常有4-8个)
- 选择5个可用的SATA接口
- 将5根SATA数据线连接到主板的SATA接口
- 注意:SATA数据线有方向,L形接口只能一个方向插入
**步骤4.3:连接SATA电源线**
- 从电源找到SATA电源线
- SATA电源线通常有多个接口(一条线上有2-4个接口)
- 如果一条线上有多个接口,可以连接多块硬盘
- 如果接口不足,需要使用SATA电源分线器
- 将SATA电源线连接到5块硬盘
**步骤4.4:连接SATA数据线到硬盘**
- 将SATA数据线的另一端连接到5块硬盘
- 注意:SATA数据线连接到硬盘的SATA数据接口(较小的L形接口)
- 确保连接牢固
**步骤4.5:固定硬盘(可选但建议)**
- 如果机箱有硬盘位,将硬盘安装到硬盘位
- 使用螺丝固定硬盘
- 如果机箱没有硬盘位,将硬盘放在安全的位置
- 注意:避免硬盘震动,确保有良好的通风
5. **准备USB连接(2块硬盘)**
**步骤5.1:安装硬盘到USB硬盘盒**
- 打开USB硬盘盒
- 将2块硬盘安装到USB硬盘盒中
- 固定硬盘(按照硬盘盒说明书操作)
- 关闭USB硬盘盒
**步骤5.2:连接USB线**
- 将USB线连接到USB硬盘盒
- 将USB线连接到Windows电脑的USB 3.0接口
- 建议:使用USB 3.0接口(通常是蓝色的)
**步骤5.3:连接电源适配器**
- 将USB硬盘盒的电源适配器连接到电源插座
- 将电源适配器的DC线连接到USB硬盘盒
- 确保电源适配器正常工作(通常有指示灯)
6. **关闭机箱**
- 确保所有连接正确
- 关闭电脑机箱侧板
- 注意:不要完全关闭,先测试连接是否正常
7. **检查连接状态**
**步骤7.1:启动Windows电脑**
- 连接电源
- 启动Windows电脑
- 等待系统完全启动
**步骤7.2:检查设备管理器**
- 右键点击"此电脑" → "属性" → "设备管理器"
- 或:Win + X → "设备管理器"
- 展开"磁盘驱动器"
- 应该能看到所有7块硬盘(5块SATA + 2块USB)
- 记录每块硬盘的设备名称(例如:WDC WD80EFBX, Disk 1, Disk 2, ...)
**步骤7.3:检查磁盘管理**
- Win + X → "磁盘管理"
- 应该能看到所有7块硬盘
- 硬盘可能显示为"未初始化"或"未分配"(这是正常的,因为ZFS文件系统Windows不识别)
- 记录每块硬盘的磁盘编号(例如:磁盘0, 磁盘1, ...)
**检查清单**:
- [ ] TrueNAS系统已关闭
- [ ] Windows电脑已关闭并断电
- [ ] 所有7块硬盘已从TrueNAS移除
- [ ] 5块硬盘已通过SATA连接到Windows电脑
- [ ] 2块硬盘已通过USB硬盘盒连接到Windows电脑
- [ ] 所有SATA数据线已连接
- [ ] 所有SATA电源线已连接
- [ ] 所有USB硬盘盒电源适配器已连接
- [ ] 硬盘已固定(如果可能)
- [ ] Windows电脑已启动
- [ ] 设备管理器中能看到所有7块硬盘
- [ ] 磁盘管理中能看到所有7块硬盘
- [ ] 硬盘没有异常声音或发热
---
### 阶段2:软件准备和配置
#### 步骤2.1:准备恢复环境
**操作**:
1. 确保UFS Explorer Professional Recovery已安装
2. 准备至少2TB可用空间的存储位置(用于保存恢复的文件)
3. 建议使用外部USB硬盘或NAS作为保存位置
**检查清单**:
- [ ] UFS Explorer已安装并可运行
- [ ] 有至少2TB可用空间(建议3TB,留有余量)
- [ ] 保存位置使用NTFS文件系统(支持大文件)
- [ ] 保存位置不是系统盘(C盘)
#### 步骤2.2:配置UFS Explorer
**操作**:
1. 启动UFS Explorer Professional Recovery
2. 进入设置(Settings)
3. 配置以下选项:
- **Path for temporary files**:选择有足够空间的临时文件目录(至少10GB)
- **Default FS policy for defective blocks**:选择"Ignore"(忽略坏块,继续恢复)
- **Process (include) Windows logical drives**:启用(处理Windows逻辑驱动器)
- **System memory size for file cache (GB)**:根据系统内存设置(建议至少4GB)
**检查清单**:
- [ ] 临时文件目录已设置
- [ ] 软件设置已配置
- [ ] 软件可以正常运行
---
### 阶段3:识别ZFS存储池
#### 步骤3.1:查看连接的存储设备
**操作**:
1. 在UFS Explorer左侧面板,查看"Connected storages"
2. 应该能看到所有7块硬盘
3. 记录每块硬盘的显示名称和大小
**预期结果**:
- 应该能看到7块8TB硬盘
- 硬盘可能显示为"WDC WD80EFBX"或类似名称
- 每块硬盘大小应该约8TB(7.27 TiB)
**检查清单**:
- [ ] 所有7块硬盘都在列表中显示
- [ ] 硬盘大小正确(约8TB)
- [ ] 没有错误或警告信息
#### 步骤3.2:识别ZFS存储池
**操作**:
1. 在UFS Explorer中查找ZFS存储池
2. 查找名为"PoolOne"的存储池
3. 或查找显示"ZFS"或"raidz2"标识的存储设备
**如果自动识别成功**:
- ✅ 应该能看到"PoolOne"存储池
- ✅ 存储池大小应该约48TB(7块8TB硬盘,raidz2配置)
- ✅ 可以继续到步骤3.3
**如果无法自动识别**:
- ⚠️ 可能需要手动扫描
- ⚠️ 转到步骤3.3
#### 步骤3.3:手动扫描(如果需要)
**操作**:
1. 在UFS Explorer中,选择菜单:Tools → Scan for lost data
2. 选择所有7块硬盘
3. 选择文件系统类型:ZFS
4. 点击"Scan"开始扫描
**注意事项**:
- ⚠️ 扫描可能需要很长时间(数小时)
- ⚠️ 扫描过程中不要关闭软件或断开硬盘
- ⚠️ 扫描完成后,查看是否有"PoolOne"存储池
**检查清单**:
- [ ] 扫描已完成
- [ ] 找到了ZFS存储池
- [ ] 存储池名称是"PoolOne"或类似名称
---
### 阶段4:浏览和恢复文件
#### 步骤4.1:打开ZFS存储池
**操作**:
1. 在UFS Explorer中,双击"PoolOne"存储池
2. 浏览目录结构
3. 找到目标目录:`apps/youyidian-3d/uploads/`
**预期目录结构**:
```
PoolOne
└── apps
└── youyidian-3d
└── uploads
├── [文件1]
├── [文件2]
└── ...
```
**检查清单**:
- [ ] 可以打开存储池
- [ ] 可以浏览目录结构
- [ ] 找到了uploads目录
#### 步骤4.2:检查文件状态
**操作**:
1. 进入uploads目录
2. 查看文件列表
3. 检查文件状态(是否有损坏标记)
**文件状态说明**:
- ✅ 正常文件:可以正常显示和恢复
- ⚠️ 损坏文件:可能有部分数据损坏
- ❌ 无法访问:文件可能已被覆盖或损坏
**记录信息**:
- 文件总数
- 总大小
- 损坏文件数量
- 无法访问文件数量
#### 步骤4.3:选择要恢复的文件
**操作**:
1. 进入uploads目录
2. 选择所有文件(Ctrl+A)
3. 或选择特定文件/文件夹
**建议**:
- ✅ 如果文件很多,可以分批恢复
- ✅ 先恢复少量文件测试,确认恢复过程正常
- ✅ 然后恢复所有文件
#### 步骤4.4:开始恢复
**操作**:
1. 右键点击选择的文件/文件夹
2. 选择"Copy"或"Save files"
3. 选择保存位置(需要至少2TB空间)
4. 配置恢复选项:
- **Preserve folder structure**:启用(保留文件夹结构)
- **Duplicate file conflict action**:选择"Ask what to do"或"Rename"
- **When a read error occurs**:选择"Retry"或"Ignore"
5. 点击"OK"或"Save"开始恢复
**检查清单**:
- [ ] 保存位置有足够空间(至少2TB)
- [ ] 恢复选项已配置
- [ ] 开始恢复
---
### 阶段5:监控恢复过程
#### 步骤5.1:监控恢复进度
**操作**:
1. 查看恢复进度对话框
2. 监控以下信息:
- 文件处理进度
- 数据复制进度
- 恢复速度
- 预计剩余时间
- 错误和警告
**关键指标**:
- **Files processed**:已处理文件数 / 总文件数
- **Data processed**:已处理数据 / 总数据
- **Speed**:恢复速度(MB/s)
- **Time remaining**:预计剩余时间
#### 步骤5.2:处理错误和警告
**常见问题**:
1. **读取错误(Read error)**
- **原因**:硬盘有坏道或连接不稳定
- **处理**:软件会自动重试,如果持续失败会跳过该文件
- **建议**:记录错误文件,恢复完成后检查
2. **连接中断(Connection lost)**
- **原因**:USB连接不稳定或电源问题
- **处理**:重新连接硬盘,从断点继续恢复
- **建议**:检查USB连接和电源供应
3. **磁盘空间不足(Disk full)**
- **原因**:保存位置空间不足
- **处理**:停止恢复,释放空间或更换保存位置
- **建议**:确保有足够的空间(至少2TB,建议3TB)
#### 步骤5.3:保持系统稳定
**注意事项**:
- ✅ 不要关闭UFS Explorer软件
- ✅ 不要断开USB连接
- ✅ 不要关闭电脑或进入休眠模式
- ✅ 不要移动USB硬盘盒
- ✅ 定期检查连接状态
- ✅ 监控硬盘温度和异常声音
**预计恢复时间**:
- USB 3.0连接:约3-4小时(1.5TB数据)
- USB 2.0连接:约10-14小时(不推荐)
- SATA连接:约2-3小时(如果使用混合连接)
---
### 阶段6:验证恢复结果
#### 步骤6.1:检查恢复的文件
**操作**:
1. 恢复完成后,打开保存位置
2. 检查uploads目录结构
3. 对比文件列表和数量
4. 检查文件大小
**检查清单**:
- [ ] uploads目录结构正确
- [ ] 文件数量与原数据一致(或接近)
- [ ] 文件大小与原数据一致(或接近)
- [ ] 没有明显的文件缺失
#### 步骤6.2:验证文件完整性
**操作**:
1. 随机选择一些文件
2. 尝试打开文件
3. 检查文件内容是否正确
**测试文件类型**:
- 图片文件:尝试打开查看
- 视频文件:尝试播放(至少播放开头部分)
- 文档文件:尝试打开查看内容
- 其他文件:根据文件类型验证
**检查清单**:
- [ ] 测试的文件可以正常打开
- [ ] 文件内容正确
- [ ] 没有明显的文件损坏
#### 步骤6.3:记录恢复结果
**记录信息**:
- 恢复的文件总数
- 恢复的数据总大小
- 恢复成功率(恢复文件数 / 原始文件数)
- 损坏或无法恢复的文件列表
- 恢复过程中的错误和警告
---
## 六、故障排查
### 问题1:无法识别所有7块硬盘
**症状**:
- "设备管理器"中只能看到部分硬盘
- UFS Explorer中只能看到部分硬盘
**可能原因**:
- USB接口不足
- USB Hub过载
- 电源供应不足
- 硬盘故障
**解决方法**:
1. 检查所有USB连接
2. 检查电源适配器连接
3. 尝试逐个连接硬盘,找出问题硬盘
4. 使用不同的USB接口
5. 如果使用USB Hub,尝试直接连接到电脑USB接口
### 问题2:无法识别ZFS存储池
**症状**:
- UFS Explorer中看不到"PoolOne"存储池
- 只能看到单独的硬盘
**可能原因**:
- 缺少硬盘(未连接所有7块硬盘)
- ZFS存储池损坏
- UFS Explorer不支持ZFS或raidz2配置
**解决方法**:
1. 确认所有7块硬盘都已连接
2. 尝试手动扫描:Tools → Scan for lost data
3. 选择所有7块硬盘进行扫描
4. 如果仍然无法识别,可能需要使用其他恢复工具
### 问题3:恢复速度很慢
**症状**:
- 恢复速度 < 10 MB/s
- 预计恢复时间 > 20小时
**可能原因**:
- USB 2.0连接(速度限制)
- USB Hub过载
- 硬盘有坏道
- 数据碎片化严重
**解决方法**:
1. 确认使用USB 3.0或更高速度
2. 如果使用USB Hub,尝试直接连接
3. 检查硬盘健康状态
4. 如果可能,使用SATA连接替代USB连接
### 问题4:恢复过程中连接中断
**症状**:
- USB连接突然断开
- 恢复过程中断
- 硬盘无法访问
**可能原因**:
- USB连接不稳定
- 电源供应不足
- USB Hub过载
- 硬盘故障
**解决方法**:
1. 重新连接USB设备
2. 检查电源适配器
3. 如果使用USB Hub,尝试直接连接
4. 从断点继续恢复(UFS Explorer应该支持)
### 问题5:恢复的文件损坏
**症状**:
- 恢复的文件无法打开
- 文件大小不正确
- 文件内容乱码
**可能原因**:
- 数据已被覆盖
- ZFS删除机制导致数据不可恢复
- 硬盘有物理损坏
- 恢复过程中出现错误
**解决方法**:
1. 检查其他文件是否正常
2. 如果大部分文件损坏,可能需要使用其他恢复方法
3. 考虑专业数据恢复服务
---
## 七、成功标准
恢复完成后,检查以下标准:
### 基本要求
- ✅ uploads目录结构完整
- ✅ 文件数量恢复率 ≥ 80%(考虑到可能的文件覆盖)
- ✅ 文件大小恢复率 ≥ 80%
- ✅ 关键文件可以正常打开和使用
### 理想标准
- ✅ 文件数量恢复率 ≥ 95%
- ✅ 文件大小恢复率 ≥ 95%
- ✅ 所有测试文件都可以正常打开
- ✅ 文件完整性验证通过
### 可接受标准
- ✅ 文件数量恢复率 ≥ 50%
- ✅ 关键业务文件可以恢复
- ✅ 部分文件损坏但可以接受
---
## 八、恢复数据验证
### 步骤8.1:运行恢复后清单导出
**操作**:
1. 数据恢复完成后,再次运行导出脚本
```bash
npx tsx scripts/export-material-inventory.ts
```
2. 保存新的清单文件(文件名包含新的时间戳)
### 步骤8.2:对比恢复前后的清单
**操作**:
1. 运行对比脚本
```bash
npx tsx scripts/compare-material-inventory.ts <恢复前JSON文件> <恢复后JSON文件>
```
2. 查看对比报告:
- 文本报告:查看总体统计和详细清单
- CSV报告:在Excel中查看和筛选
3. 分析恢复结果:
- 查看"完全恢复"、"部分恢复"、"未恢复"的素材
- 记录恢复成功率和缺失的文件
### 步骤8.3:验证文件完整性
**操作**:
1. 随机选择一些恢复的文件
2. 尝试打开文件,检查是否可以正常使用
3. 对比文件大小和数据库记录
4. 记录验证结果
---
## 九、恢复数据传回服务器
### ⚠️ 重要:恢复数据的存放位置
**问题**:恢复的数据(约1.5TB)需要先存放在哪里?
**推荐方案**:先恢复到本地存储,验证后再传输到服务器
**原因**:
- 恢复过程不受网络限制
- 可以方便地验证数据完整性
- 避免网络传输中断导致的问题
**要求**:
- 本地需要有足够空间(至少2TB,建议3TB)
- 建议使用NTFS格式的外部硬盘或NAS
- 确保存储设备稳定可靠
### 步骤9.1:准备服务器存储空间
**操作**:
1. 通过SSH连接到服务器检查空间
```bash
ssh root@192.168.58.20
df -h /mnt/PoolOne
```
2. 确认uploads目录状态
```bash
ls -la /mnt/PoolOne/apps/youyidian-3d/uploads
du -sh /mnt/PoolOne/apps/youyidian-3d/uploads
```
3. 如果目录不为空,考虑清理或备份现有文件
### 步骤9.2:传输数据到服务器
**方法1:使用SCP传输(推荐用于小批量文件)**
```bash
# 从Windows传输到服务器
scp -r "本地恢复数据目录" root@192.168.58.20:/mnt/PoolOne/apps/youyidian-3d/uploads/
```
**方法2:使用rsync传输(推荐用于大量文件)**
如果Windows上有rsync工具(如通过Git Bash或WSL):
```bash
# 同步恢复的数据到服务器
rsync -avz --progress "本地恢复数据目录/" root@192.168.58.20:/mnt/PoolOne/apps/youyidian-3d/uploads/
```
**方法3:使用图形化SCP工具(如WinSCP)**
1. 连接服务器:`192.168.58.20`,用户名:`root`
2. 导航到目标目录:`/mnt/PoolOne/apps/youyidian-3d/uploads`
3. 上传恢复的文件
**注意事项**:
- 传输1.5TB数据需要较长时间(取决于网络速度)
- 建议在稳定的网络环境下进行
- 如果传输中断,可以使用rsync的断点续传功能
- 传输完成后验证文件数量
### 步骤9.3:验证服务器上的数据
**操作**:
1. 检查文件数量
```bash
ssh root@192.168.58.20
ls -la /mnt/PoolOne/apps/youyidian-3d/uploads | wc -l
```
2. 检查目录大小
```bash
du -sh /mnt/PoolOne/apps/youyidian-3d/uploads
```
3. 随机检查一些文件
```bash
ls -lh /mnt/PoolOne/apps/youyidian-3d/uploads | head -20
```
4. 检查文件权限
```bash
chmod -R 644 /mnt/PoolOne/apps/youyidian-3d/uploads/*
chmod -R 755 /mnt/PoolOne/apps/youyidian-3d/uploads
```
### 步骤9.4:重启服务并验证
**操作**:
1. 重启应用服务(如果使用Docker)
```bash
cd /mnt/PoolOne/apps/youyidian-3d
docker-compose restart
```
2. 检查应用日志,确认没有错误
3. 在Web界面验证素材是否可以正常下载
---
## 十、后续步骤
恢复完成后:
1. **备份恢复的数据**
- 将恢复的文件备份到安全位置
- 建议使用外部硬盘或NAS
- 创建多个备份副本
2. **验证数据完整性**
- 详细检查恢复的文件
- 对比文件列表和大小
- 测试关键文件的可用性
3. **记录恢复过程**
- 记录恢复的成功率和遇到的问题
- 总结经验和教训
- 为未来类似情况提供参考
4. **建立预防措施**
- 设置定期备份
- 配置ZFS快照
- 建立监控和告警机制
---
## 九、成本和时间估算(基于混合连接方案)
### 硬件成本
**已有硬件**(无需购买):
- ✅ 2个USB硬盘盒
- ✅ 所有7块TrueNAS硬盘
- ✅ Windows电脑(有5个SATA接口)
**需要购买的硬件**:
| 项目 | 数量 | 单价 | 总价 |
|------|------|------|------|
| SATA数据线 | 5根 | 10-20元 | **50-100元** |
| SATA电源分线器(如果需要) | 1个 | 20-50元 | 20-50元(可选) |
| 硬盘固定螺丝(如果需要) | 若干 | 5-10元 | 5-10元(可选) |
**总成本**:约**50-160元**(非常经济!)
**采购建议**:
- SATA数据线:建议购买SATA 3.0数据线(支持6 Gbps速度)
- 长度建议:30-50cm(太短不方便,太长影响信号)
- 购买渠道:京东、淘宝、电脑城等
### 时间估算
| 阶段 | 预计时间 |
|------|---------|
| 硬件准备和连接 | 1-2小时 |
| 软件准备和配置 | 0.5小时 |
| 识别ZFS存储池 | 0.5-2小时(如果扫描) |
| 浏览和选择文件 | 0.5小时 |
| **数据恢复** | **3-4小时(USB 3.0)** |
| 验证恢复结果 | 1-2小时 |
| **总计** | **约6-11小时** |
**注意**:实际时间取决于数据量、USB速度和遇到的问题。
---
## 十、重要提醒
### ⚠️ 关键限制
1. **必须连接所有7块硬盘**
- raidz2配置需要全部硬盘同时访问
- 缺少任何一块都无法完整恢复
2. **USB连接的限制**
- USB速度可能限制恢复速度
- USB连接可能不稳定
- 需要足够的USB接口和电源
3. **数据恢复的不确定性**
- ZFS删除机制可能导致部分数据无法恢复
- 恢复成功率取决于数据是否已被覆盖
- 无法保证100%恢复
### ✅ 建议
1. **优先使用SATA连接**(如果可能)
- SATA连接速度更快、更稳定
- 如果可以,建议使用混合连接方案
2. **准备足够的硬件**
- 确保有足够的USB硬盘盒或SATA接口
- 确保电源供应充足
3. **保持耐心**
- 数据恢复需要时间
- 不要中断恢复过程
- 定期检查但不频繁操作
4. **记录过程**
- 记录每一步操作
- 记录遇到的问题和解决方法
- 为未来提供参考
---
**文档创建时间**:2026-01-13
**适用版本**:UFS Explorer Professional Recovery 10.11.1
**恢复目标**:TrueNAS ZFS存储池 PoolOne/apps/youyidian-3d/uploads
**硬件配置**:2个USB硬盘盒 + Windows系统

View File

@ -20,7 +20,7 @@ export async function PUT(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -28,8 +28,10 @@ export async function PUT(
}
// 检查权限
const isSuperAdminUser = await isSuperAdmin(session.user.id);
const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser);
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
return NextResponse.json(
@ -91,7 +93,7 @@ export async function DELETE(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -99,8 +101,10 @@ export async function DELETE(
}
// 检查权限
const isSuperAdminUser = await isSuperAdmin(session.user.id);
const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser);
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
return NextResponse.json(

View File

@ -16,7 +16,7 @@ import { getApiKeys, createApiKey } from '@/lib/external-api';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -24,8 +24,10 @@ export async function GET(request: NextRequest) {
}
// 检查权限(仅超级管理员和系统管理员可访问)
const isSuperAdminUser = await isSuperAdmin(session.user.id);
const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser);
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
return NextResponse.json(
@ -61,7 +63,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -69,8 +71,10 @@ export async function POST(request: NextRequest) {
}
// 检查权限
const isSuperAdminUser = await isSuperAdmin(session.user.id);
const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser);
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
return NextResponse.json(
@ -89,7 +93,7 @@ export async function POST(request: NextRequest) {
);
}
const apiKey = await createApiKey(name.trim(), session.user.id);
const apiKey = await createApiKey(name.trim(), userId);
return NextResponse.json({
success: true,

View File

@ -12,7 +12,7 @@ import { getApiCallLogs } from '@/lib/external-api';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -20,8 +20,10 @@ export async function GET(request: NextRequest) {
}
// 检查权限
const isSuperAdminUser = await isSuperAdmin(session.user.id);
const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser);
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
return NextResponse.json(

View File

@ -26,15 +26,24 @@ export async function POST(request: NextRequest) {
}
})
} else {
// 软删除:设置deletedAt字段
await prisma.material.updateMany({
where: {
id: { in: ids }
},
data: {
deletedAt: new Date()
}
// 软删除:设置deletedAt字段,同时将任务号作废
// 需要逐条处理,因为每条记录的任务号后缀不同
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
select: { id: true, taskNo: true }
})
const now = Date.now()
await Promise.all(materials.map((m, index) =>
prisma.material.update({
where: { id: m.id },
data: {
deletedAt: new Date(),
// 如果有任务号,加上删除时间戳后缀使其作废
...(m.taskNo ? { taskNo: `${m.taskNo}_DELETED_${now}_${index}` } : {})
}
})
))
}
return NextResponse.json({

View File

@ -9,8 +9,15 @@ import { isSystemAdmin } from '@/lib/department-permission';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
// 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以
if (!session || !isSystemAdmin(session.user?.role)) {
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '权限不足' },
{ status: 403 }
@ -48,7 +55,8 @@ export async function POST(request: NextRequest) {
}
// 不能修改自己的角色
if (user.email === session.user?.email) {
const sessionUserEmail = (session.user as any).email;
if (user.email === sessionUserEmail) {
return NextResponse.json(
{ error: '不能修改自己的角色' },
{ status: 400 }

View File

@ -9,7 +9,15 @@ import { isSystemAdmin } from '@/lib/department-permission';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -56,8 +64,9 @@ export async function POST(request: NextRequest) {
);
}
const sessionUserId = (session.user as any).id;
// 不能禁用自己
if (userId === session.user.id) {
if (userId === sessionUserId) {
return NextResponse.json(
{ error: '不能禁用自己' },
{ status: 400 }

View File

@ -9,7 +9,15 @@ import { isSystemAdmin } from '@/lib/department-permission';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -25,8 +33,9 @@ export async function POST(request: NextRequest) {
);
}
const sessionUserId = (session.user as any).id;
// 不能修改自己的下载权限
if (userId === session.user.id) {
if (userId === sessionUserId) {
return NextResponse.json(
{ error: '不能修改自己的下载权限' },
{ status: 400 }

View File

@ -97,7 +97,14 @@ export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
@ -155,7 +162,14 @@ export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}

View File

@ -115,11 +115,12 @@ export async function POST(request: NextRequest) {
const randomName = anonymousNames[Math.floor(Math.random() * anonymousNames.length)];
// 创建弹幕
const userId = (session.user as any).id;
const danmaku = await prisma.danmaku.create({
data: {
content: content.trim(),
username: randomName,
userId: session.user.id,
userId: userId,
},
});

View File

@ -57,58 +57,76 @@ export async function GET(request: NextRequest) {
// 后续需要实现:根据 API Key 或用户权限检查下载权限
// 参考:/api/materials/download 中的权限检查逻辑
// 产品全案:使用现有逻辑
if (material.materialType === '产品全案') {
// 单个资产下载
if (downloadType === 'single' && assetId) {
const asset = material.assets.find((a: any) => a.id === assetId);
if (!asset) {
return NextResponse.json(
{ success: false, error: { code: 'ASSET_NOT_FOUND', message: '资产不存在' } },
{ status: 404 }
);
// 任务类型素材:使用assets逻辑
const isTaskMaterial = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'].includes(material.materialType);
if (isTaskMaterial) {
if (material.materialType === '任务-详情') {
// 任务-详情:保持现有逻辑
// 单个资产下载
if (downloadType === 'single' && assetId) {
const asset = material.assets.find((a: any) => a.id === assetId);
if (!asset) {
return NextResponse.json(
{ success: false, error: { code: 'ASSET_NOT_FOUND', message: '资产不存在' } },
{ status: 404 }
);
}
return await downloadSingleFile(asset);
}
return await downloadSingleFile(asset);
}
// PSD 下载
if (downloadType === 'psd') {
if (assetId) {
const psdAsset = material.assets.find((a: any) => a.id === assetId && a.assetType === ASSET_TYPE.PSD);
if (!psdAsset) {
// PSD 下载
if (downloadType === 'psd') {
if (assetId) {
const psdAsset = material.assets.find((a: any) => a.id === assetId && a.assetType === ASSET_TYPE.PSD);
if (!psdAsset) {
return NextResponse.json(
{ success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } },
{ status: 404 }
);
}
return await downloadSingleFile(psdAsset);
}
const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD);
if (psdAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } },
{ status: 404 }
);
}
return await downloadSingleFile(psdAsset);
if (psdAssets.length === 1) {
return await downloadSingleFile(psdAssets[0]);
}
return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd');
}
const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD);
if (psdAssets.length === 0) {
// 打包下载
const assetsToDownload = getAssetsForDownload(material.assets, downloadType);
if (assetsToDownload.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } },
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
return await downloadAsZip(assetsToDownload, material.taskNo || material.id, downloadType);
} else {
// 任务-3D原图/AI原图/实拍原图:下载zip文件
const zipAssets = material.assets.filter((a: any) => a.assetType === 'zip');
if (zipAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } },
{ status: 404 }
);
}
if (psdAssets.length === 1) {
return await downloadSingleFile(psdAssets[0]);
}
return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd');
// 返回第一个zip文件
const zipAsset = zipAssets[0];
return await downloadSingleFile(zipAsset);
}
// 打包下载
const assetsToDownload = getAssetsForDownload(material.assets, downloadType);
if (assetsToDownload.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
return await downloadAsZip(assetsToDownload, material.taskNo || material.id, downloadType);
}
// 其他素材类型:复用 /api/materials/download 的逻辑
@ -293,7 +311,7 @@ function isHttpUrl(url?: string | null): boolean {
}
/**
* 下载非产品全案类型的素材
* 下载非任务-详情类型的素材
* 复用 /api/materials/download 的逻辑,但跳过权限检查
*/
async function downloadNonProductPackageMaterial(material: any, downloadType: string, request: NextRequest) {

View File

@ -1,5 +1,5 @@
/**
* 外部接口:产品全案素材查询和删除
* 外部接口:任务-详情素材查询和删除
* GET - 通过任务号查询素材详情
* DELETE - 通过任务号删除素材
*/
@ -79,7 +79,14 @@ export async function GET(request: NextRequest) {
// 查询素材
console.log('[external/product-assets] Querying material for taskNo:', taskNo);
const material: any = await getMaterialByTaskNo(taskNo);
console.log('[external/product-assets] Material found:', material ? { id: material.id, taskNo: material.taskNo, assetsCount: material.assets?.length || 0 } : 'null');
console.log('[external/product-assets] Material found:', material ? {
id: material.id,
taskNo: material.taskNo,
materialType: material.materialType,
taskStatus: material.taskStatus,
assetsCount: material.assets?.length || 0,
hasPreviewUrl: !!material.previewUrl,
} : 'null');
if (!material) {
await logApiCall({
@ -121,17 +128,47 @@ export async function GET(request: NextRequest) {
console.log('[external/product-assets] Material type:', material.materialType);
// 根据素材类型返回不同结构
if (material.materialType === '产品全案') {
// 产品全案:保持现有结构
const isTaskMaterial = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'].includes(material.materialType);
if (isTaskMaterial) {
// 任务类型素材:返回assets结构
if (material.materialType === '任务-详情') {
// 任务-详情:保持现有结构(main/sku/detail/psd)
const mainAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.MAIN);
const skuAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.SKU);
const detailAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.DETAIL);
const psdAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.PSD);
console.log('[external/product-assets] Assets grouped:', { main: mainAssets.length, sku: skuAssets.length, detail: detailAssets.length, psd: psdAssets.length });
// 检查是否有资产(任务-详情至少应该有主图、SKU图或详情图)
if (mainAssets.length === 0 && skuAssets.length === 0 && detailAssets.length === 0) {
console.warn('[external/product-assets] Warning: Task detail material has no assets:', {
materialId: material.id,
taskNo: material.taskNo,
});
}
// 生成资产列表(带下载链接)
const formatAsset = (asset: any, downloadType: DownloadType) => {
try {
// 验证资产数据
if (!asset || !asset.id) {
throw new Error(`Invalid asset data: ${JSON.stringify(asset)}`);
}
if (!asset.fileUrl) {
console.warn('[external/product-assets] Asset has no fileUrl:', {
assetId: asset.id,
assetType: asset.assetType,
fileName: asset.fileName,
});
}
if (!material.id) {
throw new Error(`Material has no id: ${JSON.stringify({ taskNo: material.taskNo, materialId: material.id })}`);
}
const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request);
const downloadUrl = generateDownloadUrl(baseUrl, {
materialId: material.id,
@ -142,15 +179,18 @@ export async function GET(request: NextRequest) {
id: asset.id,
url: rewrittenUrl,
downloadUrl,
fileName: asset.fileName,
fileSize: Number(asset.fileSize),
...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder }),
fileName: asset.fileName || '',
fileSize: Number(asset.fileSize || 0),
...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder || 0 }),
};
} catch (formatError) {
console.error('[external/product-assets] Error formatting asset:', {
assetId: asset.id,
assetType: asset.assetType,
assetId: asset?.id,
assetType: asset?.assetType,
materialId: material.id,
taskNo: material.taskNo,
error: formatError instanceof Error ? formatError.message : String(formatError),
stack: formatError instanceof Error ? formatError.stack : undefined,
});
throw formatError;
}
@ -191,6 +231,91 @@ export async function GET(request: NextRequest) {
});
return successResponse(responseData);
} else {
// 任务-3D原图/AI原图/实拍原图:返回preview和zip结构
const previewAssets = (material.assets || []).filter((a: any) => a.assetType === 'preview');
const zipAssets = (material.assets || []).filter((a: any) => a.assetType === 'zip');
console.log('[external/product-assets] Assets grouped:', { preview: previewAssets.length, zip: zipAssets.length });
if (previewAssets.length === 0 && zipAssets.length === 0) {
console.warn('[external/product-assets] Warning: Task material has no assets:', {
materialId: material.id,
taskNo: material.taskNo,
materialType: material.materialType,
});
}
// 生成资产列表(带下载链接)
const formatAsset = (asset: any) => {
try {
if (!asset || !asset.id) {
throw new Error(`Invalid asset data: ${JSON.stringify(asset)}`);
}
if (!asset.fileUrl) {
console.warn('[external/product-assets] Asset has no fileUrl:', {
assetId: asset.id,
assetType: asset.assetType,
fileName: asset.fileName,
});
}
const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request);
const downloadUrl = asset.assetType === 'zip'
? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' })
: rewrittenUrl; // preview图直接使用URL
return {
id: asset.id,
url: rewrittenUrl,
downloadUrl,
fileName: asset.fileName || '',
fileSize: Number(asset.fileSize || 0),
...(asset.assetType === 'preview' && { sortOrder: asset.sortOrder || 0 }),
};
} catch (formatError) {
console.error('[external/product-assets] Error formatting asset:', {
assetId: asset?.id,
assetType: asset?.assetType,
materialId: material.id,
taskNo: material.taskNo,
error: formatError instanceof Error ? formatError.message : String(formatError),
});
throw formatError;
}
};
const responseData = {
taskNo: material.taskNo,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
materialType: material.materialType,
status: TASK_STATUS.FETCHED,
createdAt: material.createdAt.toISOString(),
updatedAt: material.updatedAt.toISOString(),
assets: {
preview: previewAssets.map((a: any) => formatAsset(a)),
zip: zipAssets.map((a: any) => formatAsset(a)),
},
downloadUrls: {
all: zipAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }) : null,
},
};
await logApiCall({
apiKeyId: keyRecord.id,
endpoint: '/api/external/product-assets',
method: 'GET',
taskNo,
statusCode: 200,
duration: Date.now() - startTime,
ipAddress: getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
});
return successResponse(responseData);
}
} else {
// 其他素材类型:返回统一的 files 结构和单个下载链接
const previewUrl = material.previewUrl || '';
@ -257,19 +382,28 @@ export async function GET(request: NextRequest) {
taskNo,
error: errorMessage,
stack: errorStack,
apiKeyId: keyRecord.id,
apiKeyId: keyRecord?.id,
timestamp: new Date().toISOString(),
});
await logApiCall({
apiKeyId: keyRecord.id,
endpoint: '/api/external/product-assets',
method: 'GET',
taskNo,
statusCode: 500,
duration: Date.now() - startTime,
ipAddress: getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
});
// 尝试记录日志(即使keyRecord可能为null)
try {
if (keyRecord) {
await logApiCall({
apiKeyId: keyRecord.id,
endpoint: '/api/external/product-assets',
method: 'GET',
taskNo,
statusCode: 500,
duration: Date.now() - startTime,
ipAddress: getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
});
}
} catch (logError) {
console.error('[external/product-assets] Failed to log API call:', logError);
}
return errorResponse(API_ERROR.SERVER_ERROR);
}
}

View File

@ -20,7 +20,7 @@ export async function GET(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -67,13 +67,14 @@ export async function POST(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const materialId = params.id;
const body = await request.json();
const { assetType, fileUrl, fileName, fileSize, sortOrder } = body;
@ -99,7 +100,7 @@ export async function POST(
);
}
if (material.userId !== session.user.id) {
if (material.userId !== userId) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
@ -138,13 +139,14 @@ export async function DELETE(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const materialId = params.id;
const { searchParams } = new URL(request.url);
const assetId = searchParams.get('assetId');
@ -169,7 +171,7 @@ export async function DELETE(
);
}
if (material.userId !== session.user.id) {
if (material.userId !== userId) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }

View File

@ -15,7 +15,7 @@ export async function PUT(
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -47,7 +47,8 @@ export async function PUT(
);
}
if (material.userId !== session.user.id) {
const userId = (session.user as any).id;
if (material.userId !== userId) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }

View File

@ -10,7 +10,7 @@ export async function POST(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -32,12 +32,13 @@ export async function POST(
}
// 检查权限:只有素材上传者或管理员可以重试转码
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
if (material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限重试转码' }, { status: 403 })
}

View File

@ -14,13 +14,14 @@ export async function GET(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
// 检查用户下载权限
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { canDownload: true }
})

View File

@ -52,7 +52,7 @@ export async function PUT(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -78,12 +78,13 @@ export async function PUT(
}
// 检查权限:只有素材上传者或管理员可以编辑 SKU
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { role: true }
})
if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') {
if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限编辑此 SKU' }, { status: 403 })
}
@ -130,7 +131,7 @@ export async function DELETE(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -153,12 +154,13 @@ export async function DELETE(
}
// 检查权限:只有素材上传者或管理员可以删除 SKU
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { role: true }
})
if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') {
if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限删除此 SKU' }, { status: 403 })
}

View File

@ -12,7 +12,7 @@ export async function PUT(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -29,12 +29,13 @@ export async function PUT(
}
// 检查权限:素材上传者或管理员可以调整排序
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
if (material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限调整排序' }, { status: 403 })
}

View File

@ -65,7 +65,7 @@ export async function POST(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}

View File

@ -13,7 +13,7 @@ export async function POST(
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -34,12 +34,13 @@ export async function POST(
}
// 检查权限
const userId = (session.user as any).id;
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: userId },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
if (material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限上传 SKU' }, { status: 403 })
}

View File

@ -571,8 +571,8 @@ export async function GET(request: NextRequest) {
);
}
const userId = session.user.id;
const userRole = session.user.role;
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
// 检查用户级别的下载权限
if (userRole !== 'ADMIN') {
@ -635,8 +635,9 @@ export async function GET(request: NextRequest) {
},
});
// 如果是产品全案,单独查询 assets
if (material && material.materialType === '产品全案') {
// 如果是任务类型素材,单独查询 assets
const taskMaterialTypes = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'];
if (material && taskMaterialTypes.includes(material.materialType)) {
const assets = await prisma.materialAsset.findMany({
where: {
materialId: material.id,
@ -757,8 +758,9 @@ export async function GET(request: NextRequest) {
},
});
// 如果是产品全案,单独查询 assets
if (material && material.materialType === '产品全案') {
// 如果是任务类型素材,单独查询 assets
const taskMaterialTypes = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'];
if (material && taskMaterialTypes.includes(material.materialType)) {
const assets = await prisma.materialAsset.findMany({
where: { materialId: material.id },
select: {
@ -814,9 +816,9 @@ export async function GET(request: NextRequest) {
);
}
// 产品全案:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
// 任务-详情:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
// 如果下载的是 PSD,需要额外检查"详情模板"类型的源文件下载权限
if (material.materialType === '产品全案' && downloadType === 'psd') {
if (material.materialType === '任务-详情' && downloadType === 'psd') {
const canDownloadPsd = await canUserDownloadSource(
userDepartment,
userRole,
@ -832,20 +834,40 @@ export async function GET(request: NextRequest) {
);
}
}
// 新任务类型:ZIP下载权限参考"3D场景"类型的 canDownloadSource 权限
const newTaskTypes = ['任务-3D原图', '任务-AI原图', '任务-实拍原图'];
if (newTaskTypes.includes(material.materialType) && downloadType === 'zip') {
const canDownloadZip = await canUserDownloadSource(
userDepartment,
userRole,
managedDepartment,
'3D场景', // 新任务类型的 ZIP 下载权限参考"3D场景"类型
userId
);
if (!canDownloadZip) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载源文件'),
{ status: 403 }
);
}
}
}
}
}
// 产品全案:处理分类下载
if (material.materialType === '产品全案' && downloadType && material.assets) {
// 如果指定了assetId,直接下载单个PSD文件(不打包)
if (assetId && downloadType === 'psd') {
// 任务-详情和新任务类型:处理分类下载
const taskMaterialTypes = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'];
if (taskMaterialTypes.includes(material.materialType) && downloadType && material.assets) {
// 任务-详情:如果指定了assetId,直接下载单个PSD文件(不打包)
if (material.materialType === '任务-详情' && assetId && downloadType === 'psd') {
const asset = (material.assets as any[]).find((a: any) => a.id === assetId && a.assetType === 'psd');
if (!asset) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
{ status: 404 }
);
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
{ status: 404 }
);
}
const filePath = asset.fileUrl;
@ -877,25 +899,77 @@ export async function GET(request: NextRequest) {
'Content-Type': mimeType,
'Content-Length': fileStats.size.toString(),
'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`,
// 静态PSD文件:长期缓存(1天),允许重新验证
'Cache-Control': 'public, max-age=86400, must-revalidate',
'Accept-Ranges': 'bytes',
// 性能优化:启用压缩(如果客户端支持)
'Vary': 'Accept-Encoding',
// 添加ETag支持
'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
},
});
} catch (error) {
console.error('[Download] PSD file not found:', absolutePath, error);
return NextResponse.json(
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
{ status: 404 }
);
createDownloadError(DownloadErrorCode.PSD_NOT_FOUND),
{ status: 404 }
);
}
}
return handleProductPackageDownload(material, downloadType);
// 新任务类型:如果指定了assetId和type为zip,直接下载ZIP文件
const newTaskTypes = ['任务-3D原图', '任务-AI原图', '任务-实拍原图'];
if (newTaskTypes.includes(material.materialType) && assetId && downloadType === 'zip') {
const asset = (material.assets as any[]).find((a: any) => a.id === assetId && a.assetType === 'zip');
if (!asset) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, 'ZIP文件不存在'),
{ status: 404 }
);
}
const filePath = asset.fileUrl;
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(process.cwd(), filePath);
try {
await access(absolutePath);
const fileStats = await stat(absolutePath);
const fileStream = createReadStream(absolutePath);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': fileStats.size.toString(),
'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.zip')}"`,
'Cache-Control': 'public, max-age=86400, must-revalidate',
'Accept-Ranges': 'bytes',
'Vary': 'Accept-Encoding',
'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`,
},
});
} catch (error) {
console.error('[Download] ZIP file not found:', absolutePath, error);
return NextResponse.json(
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, 'ZIP文件不存在'),
{ status: 404 }
);
}
}
// 任务-详情:使用专门的处理函数
if (material.materialType === '任务-详情') {
return handleProductPackageDownload(material, downloadType);
}
}
// 增加下载次数(仅非分享链接时)
@ -1055,7 +1129,7 @@ export async function GET(request: NextRequest) {
}
return NextResponse.json(
createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '文件可能已被删除'),
createDownloadError(DownloadErrorCode.FILE_MISSING, '没有源文件'),
{ status: 404 }
);
}
@ -1173,7 +1247,7 @@ export async function GET(request: NextRequest) {
if (errorMessage.includes('BigInt')) {
errorCode = DownloadErrorCode.BIGINT_SERIALIZATION_ERROR;
} else if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) {
errorCode = DownloadErrorCode.FILE_NOT_FOUND;
errorCode = DownloadErrorCode.FILE_MISSING;
details = errorMessage;
} else if (errorMessage.includes('S3') || errorMessage.includes('存储')) {
errorCode = DownloadErrorCode.S3_ACCESS_FAILED;

View File

@ -8,7 +8,7 @@ export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
const userId = session.user.id;
const userId = (session.user as any).id;
// 检查素材是否存在
const material = await prisma.material.findUnique({

View File

@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
const userId = session.user.id;
const userId = (session.user as any).id;
// 检查是否已点赞
const existingLike = await prisma.materialLike.findFirst({

View File

@ -8,7 +8,11 @@ import { createMaterialLog } from '@/lib/material-log';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}

View File

@ -137,8 +137,8 @@ export async function GET(request: NextRequest) {
let isLiked = false;
let isFavorited = false;
if (session) {
const userId = session.user.id;
if (session?.user) {
const userId = (session.user as any).id;
const materialId = material.id;
// 检查点赞
@ -222,6 +222,15 @@ export async function GET(request: NextRequest) {
{ categoryName: { contains: search, mode: 'insensitive' } },
{ sceneName: { contains: search, mode: 'insensitive' } },
{ authorName: { contains: search, mode: 'insensitive' } },
// SKU搜索:搜索关联的MaterialSku表
{
skus: {
some: {
skuName: { contains: search, mode: 'insensitive' },
deletedAt: null,
},
},
},
];
}
@ -618,8 +627,8 @@ export async function GET(request: NextRequest) {
const likedSet = new Set<string>();
const favoritedSet = new Set<string>();
if (session) {
const userId = session.user.id;
if (session?.user) {
const userId = (session.user as any).id;
const materialIds = materials.map(m => m.id);
// 批量查询点赞
@ -650,9 +659,9 @@ export async function GET(request: NextRequest) {
}
// 格式化返回数据
// 对于 Product Package 类型,需要从 assets 中获取正确的预览图
// 对于任务-详情类型,需要从 assets 中获取正确的预览图
const productPackageMaterialIds = materials
.filter(m => m.materialType === '产品全案')
.filter(m => m.materialType === '任务-详情')
.map(m => m.id);
// 批量获取 Product Package 素材的封面图(优化:使用单次查询)
@ -721,8 +730,8 @@ export async function GET(request: NextRequest) {
// 处理previewUrl的fallback逻辑(与反编译文件一致)
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
// Product Package 类型:优先使用从 assets 获取的封面图
if (material.materialType === '产品全案') {
// 任务-详情类型:优先使用从 assets 获取的封面图
if (material.materialType === '任务-详情') {
const coverUrl = coverUrlMap.get(material.id);
if (coverUrl) {
previewUrl = coverUrl;
@ -826,7 +835,14 @@ export async function DELETE(request: NextRequest) {
const session = await getServerSession(authOptions);
// 权限检查
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -856,15 +872,22 @@ export async function DELETE(request: NextRequest) {
);
}
const userId = session.user.id;
const userId = (session.user as any).id;
// 软删除
if (!permanent) {
// 注意:软删除时,文件仍然保留,以便恢复时可以重新使用
// 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时)
// 如果有任务号,将其作废(加上删除时间戳后缀),释放原任务号供重新使用
const updateData: any = { deletedAt: new Date() };
if (material.taskNo) {
updateData.taskNo = `${material.taskNo}_DELETED_${Date.now()}`;
}
await prisma.material.update({
where: { id },
data: { deletedAt: new Date() },
data: updateData,
});
// 记录日志

View File

@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
// 获取当前登录用户(如果有)
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const userId = session?.user ? (session.user as any).id : undefined;
const material = await prisma.material.findUnique({
where: { id },
@ -78,9 +78,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 如果是产品全案,查询资产列表
// 如果是任务类型素材,查询资产列表
const taskMaterialTypes = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图'];
const isTaskMaterial = taskMaterialTypes.includes(material.materialType as unknown as string);
let assets: any[] = [];
if ((material.materialType as unknown as string) === '产品全案') {
if (isTaskMaterial) {
const rawAssets = await prisma.materialAsset.findMany({
where: {
materialId: id,
@ -99,6 +102,7 @@ export async function GET(request: NextRequest) {
// 添加详细日志
console.log(`[Share API] ========== 查询资产 ==========`);
console.log(`[Share API] MaterialId: ${id}`);
console.log(`[Share API] MaterialType: ${material.materialType}`);
console.log(`[Share API] Total assets: ${rawAssets.length}`);
rawAssets.forEach((asset: any, index: number) => {
console.log(`[Share API] Asset ${index}: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`);
@ -158,9 +162,11 @@ export async function GET(request: NextRequest) {
isFavorited = !!favorite;
}
// 产品全案:检查 PSD 下载权限(参考"详情模板"类型的权限)
// 任务类型素材:检查源文件下载权限
// - 任务-详情:参考"详情模板"类型的权限
// - 任务-3D原图/AI原图/实拍原图:参考"3D场景"类型的权限
let canDownloadSource = false;
if ((material.materialType as unknown as string) === '产品全案' && userId) {
if (isTaskMaterial && userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@ -171,12 +177,17 @@ export async function GET(request: NextRequest) {
});
if (user) {
// 产品全案的 PSD 下载权限参考"详情模板"类型的 canDownloadSource 权限
// 根据素材类型选择参考的权限类型
let referenceType = '3D场景'; // 默认参考3D场景权限
if ((material.materialType as unknown as string) === '任务-详情') {
referenceType = '详情模板';
}
canDownloadSource = await canUserDownloadSource(
user.department,
user.role,
user.managedDepartment,
'详情模板', // 使用"详情模板"类型检查权限
referenceType,
userId
);
}
@ -188,8 +199,8 @@ export async function GET(request: NextRequest) {
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
isFavorited,
assets: (material.materialType as unknown as string) === '产品全案' ? assets : undefined,
canDownloadSource: (material.materialType as unknown as string) === '产品全案' ? canDownloadSource : undefined,
assets: isTaskMaterial ? assets : undefined,
canDownloadSource: isTaskMaterial ? canDownloadSource : undefined,
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)

View File

@ -0,0 +1,413 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { unlink, mkdir, writeFile } from 'fs/promises';
import path from 'path';
import archiver from 'archiver';
import { createWriteStream } from 'fs';
import { compressImage } from '@/lib/image-processor';
// 资产类型常量
const ASSET_TYPE = {
PREVIEW: 'preview',
ZIP: 'zip',
} as const;
// 生成zip压缩包(从chunk临时目录的原图文件打包)
async function createZipFromOriginalFiles(
originalFiles: Array<{ path: string; name: string; size: number }>,
materialId: string,
productModel: string
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
// 创建目标目录
const uploadsDir = path.join(process.cwd(), 'uploads', 'materials');
await mkdir(uploadsDir, { recursive: true });
// 生成zip文件名和路径
const timestamp = Date.now();
const zipFileName = `${productModel}_${materialId}_${timestamp}.zip`;
const zipFilePath = path.join(uploadsDir, zipFileName);
const relativeZipPath = path.join('uploads', 'materials', zipFileName);
console.log('[Zip] Creating zip file:', zipFilePath);
console.log('[Zip] Original files count:', originalFiles.length);
return new Promise((resolve, reject) => {
try {
// 创建输出流
const output = createWriteStream(zipFilePath);
const archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
});
// 监听完成事件
output.on('close', () => {
console.log('[Zip] Zip file created successfully');
console.log('[Zip] Total bytes:', archive.pointer());
resolve({
filePath: relativeZipPath,
fileName: zipFileName,
fileSize: archive.pointer()
});
});
// 监听错误
archive.on('error', (err) => {
console.error('[Zip] Archive error:', err);
reject(err);
});
output.on('error', (err) => {
console.error('[Zip] Output stream error:', err);
reject(err);
});
// 连接archive到输出流
archive.pipe(output);
// 添加每个原图到zip(直接从chunk临时文件添加)
for (const file of originalFiles) {
console.log(`[Zip] Adding file: ${file.name} (${file.size} bytes)`);
archive.file(file.path, { name: file.name });
}
// 完成压缩
archive.finalize();
} catch (error) {
console.error('[Zip] Error:', error);
reject(error);
}
});
}
// 压缩原图生成预览图并上传到S3
async function compressAndUploadPreview(
originalFile: File,
materialId: string
): Promise<string> {
const buffer = Buffer.from(await originalFile.arrayBuffer());
// 压缩图片
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp'
});
// 生成文件名
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
// 上传到S3
const url = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
return url;
}
// POST - 完成3D原图上传
export async function POST(request: NextRequest) {
let materialId: string | null = null;
const tempFiles: string[] = []; // 需要清理的临时文件路径
let zipFilePath: string | null = null;
const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { materialId: matId, assets } = body;
materialId = matId;
if (!materialId || !assets) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
console.log('[3D Original Complete] Starting, materialId:', materialId);
console.log('[3D Original Complete] Preview assets (S3):', assets.preview?.length || 0);
console.log('[3D Original Complete] Main assets (uploadIds):', assets.main?.length || 0);
// 验证素材是否存在且属于当前用户
const material: any = await prisma.material.findUnique({
where: { id: materialId },
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (material.userId !== (session.user as any).id) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
);
}
// 步骤1: 从chunk临时目录读取原图文件并打包成zip
let zipInfo = null;
if (assets.main && assets.main.length > 0) {
console.log(`[3D Original] Processing ${assets.main.length} original images from chunk uploads...`);
// 从chunk临时目录读取完整的原图文件
const { getUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
originalFiles.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
});
tempFiles.push(filePath); // 记录需要清理的临时文件
} else {
throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`);
}
} catch (error) {
console.error(`[3D Original] Failed to get file for uploadId ${asset.uploadId}:`, error);
throw new Error(`获取原图文件失败: ${asset.fileName}`);
}
}
// 打包原图成zip(保存到本地文件系统)
console.log(`[3D Original] Creating zip from ${originalFiles.length} original files...`);
zipInfo = await createZipFromOriginalFiles(
originalFiles,
materialId!,
material.productModel || 'material'
);
console.log(`[3D Original] Zip created: ${zipInfo.fileName} (${zipInfo.fileSize} bytes)`);
zipFilePath = zipInfo.filePath;
}
// 步骤2: 为所有原图生成预览图并上传到S3(如果前端没有提供预览图)
// 注意:详情页需要显示所有原图的预览版本,所以每张原图都要生成预览图
if (!assets.preview || assets.preview.length === 0) {
console.log(`[3D Original] No preview provided, generating previews for all ${assets.main?.length || 0} original images...`);
if (assets.main && assets.main.length > 0) {
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { readFile } = await import('fs/promises');
// 为每张原图生成预览图
for (let i = 0; i < assets.main.length; i++) {
const original = assets.main[i];
try {
const filePath = await getUploadedFile(original.uploadId);
if (filePath) {
const buffer = await readFile(filePath);
// 压缩并上传预览图
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp',
});
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
uploadedPreviewUrls.push(previewUrl);
console.log(`[3D Original] Generated preview image ${i + 1}/${assets.main.length}:`, previewUrl);
}
} catch (err) {
console.error(`[3D Original] Failed to generate preview for image ${i + 1}:`, err);
// 继续处理其他图片,不中断整个流程
}
}
console.log(`[3D Original] Generated ${uploadedPreviewUrls.length} preview images total`);
}
}
// 准备资产数据
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName?: string;
fileSize?: bigint;
sortOrder?: number;
}> = [];
// S3预览图(前端上传的,或后端生成的)
const previewAssets = assets.preview || [];
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
uploadedPreviewUrls.forEach((url, index) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: url,
fileName: `preview-${index}.webp`,
sortOrder: index,
});
});
} else if (previewAssets.length > 0) {
// 使用前端上传的预览图
previewAssets.forEach((asset: any, index: number) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: asset.fileUrl,
fileName: asset.fileName,
fileSize: BigInt(asset.fileSize),
sortOrder: index,
});
});
}
// 添加zip资产记录
if (zipInfo) {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.ZIP,
fileUrl: zipInfo.filePath, // 本地路径
fileName: zipInfo.fileName,
fileSize: BigInt(zipInfo.fileSize),
sortOrder: 0,
});
}
// 使用事务保存所有资产记录并更新素材信息
await prisma.$transaction(async (tx) => {
// 批量创建资产记录
if (assetRecords.length > 0) {
await tx.materialAsset.createMany({
data: assetRecords,
});
}
// 更新素材记录:设置预览图和压缩包信息
let firstPreview = '';
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
firstPreview = uploadedPreviewUrls[0];
} else if (assets.preview && assets.preview.length > 0) {
// 使用前端上传的预览图
firstPreview = assets.preview[0].fileUrl;
}
await tx.material.update({
where: { id: materialId! },
data: {
previewUrl: firstPreview,
zipFilePath: zipInfo?.filePath || '',
zipFileName: zipInfo?.fileName || '',
zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0),
},
});
});
// 清理临时文件(chunk临时目录和组装的完整文件)
console.log(`[3D Original] Cleaning up ${tempFiles.length} temporary files...`);
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
console.log(`[3D Original] Deleted temp file: ${filePath}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete temp file: ${filePath}`, err);
}
}
// 清理chunk临时目录
if (assets.main && assets.main.length > 0) {
const path = await import('path');
const { rm } = await import('fs/promises');
for (const asset of assets.main) {
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', asset.uploadId);
try {
await rm(tempDir, { recursive: true, force: true });
console.log(`[3D Original] Deleted chunk temp dir: ${tempDir}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete chunk temp dir: ${tempDir}`);
}
}
}
console.log('[3D Original] Complete success!');
return NextResponse.json({
success: true,
materialId,
});
} catch (error: any) {
console.error('[3D Original Complete Error]', error);
// 清理已上传的S3预览图
if (uploadedPreviewUrls.length > 0) {
console.log('[Cleanup] Cleaning up uploaded S3 preview images...');
const { deleteFromS3 } = await import('@/lib/s3');
for (const fileUrl of uploadedPreviewUrls) {
try {
await deleteFromS3(fileUrl);
} catch (cleanupError) {
console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError);
}
}
}
// 清理本地zip文件
if (zipFilePath) {
try {
const { unlink } = await import('fs/promises');
const fullPath = path.join(process.cwd(), zipFilePath);
await unlink(fullPath);
console.log('[Cleanup] Deleted local zip file:', fullPath);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete local zip file:', cleanupError);
}
}
// 清理临时文件
if (tempFiles.length > 0) {
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
} catch (cleanupError) {
console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`);
}
}
}
// 删除素材记录
if (materialId) {
try {
await prisma.material.delete({
where: { id: materialId },
});
console.log('[Cleanup] Deleted material record:', materialId);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete material record:', cleanupError);
}
}
return NextResponse.json(
{ error: error.message || '完成上传失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// POST - 初始化3D原图素材记录
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body;
// 验证必填字段
if (!productModel || !categoryName || !sceneName) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
// 验证任务号格式(如果提供)
if (taskNo) {
const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/;
if (!taskNoRegex.test(taskNo)) {
return NextResponse.json(
{ error: '任务号格式错误' },
{ status: 400 }
);
}
// 检查任务号是否已存在(排除已删除的素材)
const existingMaterial = await prisma.material.findFirst({
where: {
taskNo,
deletedAt: null, // 只检查未删除的素材
},
select: { id: true, productModel: true, materialType: true },
});
if (existingMaterial) {
return NextResponse.json(
{ error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` },
{ status: 400 }
);
}
}
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: (session.user as any).id },
select: { username: true },
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
// 创建素材记录(使用临时预览图)
const material = await prisma.material.create({
data: {
materialType: '任务-3D原图',
productModel,
categoryName,
sceneName,
platformName: platformName || '',
productCategoryName: productCategoryName || '',
authorName: user.username,
userId: (session.user as any).id,
previewUrl: '', // 临时空值,完成上传时更新
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
renderer: '', // 3D原图不需要渲染器
taskNo: taskNo || null, // 任务号(可选)
},
});
return NextResponse.json({
success: true,
materialId: material.id,
});
} catch (error: any) {
console.error('[3D Original Init Error]', error);
return NextResponse.json(
{ error: error.message || '初始化失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,413 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { unlink, mkdir, writeFile } from 'fs/promises';
import path from 'path';
import archiver from 'archiver';
import { createWriteStream } from 'fs';
import { compressImage } from '@/lib/image-processor';
// 资产类型常量
const ASSET_TYPE = {
PREVIEW: 'preview',
ZIP: 'zip',
} as const;
// 生成zip压缩包(从chunk临时目录的原图文件打包)
async function createZipFromOriginalFiles(
originalFiles: Array<{ path: string; name: string; size: number }>,
materialId: string,
productModel: string
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
// 创建目标目录
const uploadsDir = path.join(process.cwd(), 'uploads', 'materials');
await mkdir(uploadsDir, { recursive: true });
// 生成zip文件名和路径
const timestamp = Date.now();
const zipFileName = `${productModel}_${materialId}_${timestamp}.zip`;
const zipFilePath = path.join(uploadsDir, zipFileName);
const relativeZipPath = path.join('uploads', 'materials', zipFileName);
console.log('[Zip] Creating zip file:', zipFilePath);
console.log('[Zip] Original files count:', originalFiles.length);
return new Promise((resolve, reject) => {
try {
// 创建输出流
const output = createWriteStream(zipFilePath);
const archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
});
// 监听完成事件
output.on('close', () => {
console.log('[Zip] Zip file created successfully');
console.log('[Zip] Total bytes:', archive.pointer());
resolve({
filePath: relativeZipPath,
fileName: zipFileName,
fileSize: archive.pointer()
});
});
// 监听错误
archive.on('error', (err) => {
console.error('[Zip] Archive error:', err);
reject(err);
});
output.on('error', (err) => {
console.error('[Zip] Output stream error:', err);
reject(err);
});
// 连接archive到输出流
archive.pipe(output);
// 添加每个原图到zip(直接从chunk临时文件添加)
for (const file of originalFiles) {
console.log(`[Zip] Adding file: ${file.name} (${file.size} bytes)`);
archive.file(file.path, { name: file.name });
}
// 完成压缩
archive.finalize();
} catch (error) {
console.error('[Zip] Error:', error);
reject(error);
}
});
}
// 压缩原图生成预览图并上传到S3
async function compressAndUploadPreview(
originalFile: File,
materialId: string
): Promise<string> {
const buffer = Buffer.from(await originalFile.arrayBuffer());
// 压缩图片
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp'
});
// 生成文件名
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
// 上传到S3
const url = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
return url;
}
// POST - 完成3D原图上传
export async function POST(request: NextRequest) {
let materialId: string | null = null;
const tempFiles: string[] = []; // 需要清理的临时文件路径
let zipFilePath: string | null = null;
const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { materialId: matId, assets } = body;
materialId = matId;
if (!materialId || !assets) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
console.log('[3D Original Complete] Starting, materialId:', materialId);
console.log('[3D Original Complete] Preview assets (S3):', assets.preview?.length || 0);
console.log('[3D Original Complete] Main assets (uploadIds):', assets.main?.length || 0);
// 验证素材是否存在且属于当前用户
const material: any = await prisma.material.findUnique({
where: { id: materialId },
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (material.userId !== (session.user as any).id) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
);
}
// 步骤1: 从chunk临时目录读取原图文件并打包成zip
let zipInfo = null;
if (assets.main && assets.main.length > 0) {
console.log(`[3D Original] Processing ${assets.main.length} original images from chunk uploads...`);
// 从chunk临时目录读取完整的原图文件
const { getUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
originalFiles.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
});
tempFiles.push(filePath); // 记录需要清理的临时文件
} else {
throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`);
}
} catch (error) {
console.error(`[3D Original] Failed to get file for uploadId ${asset.uploadId}:`, error);
throw new Error(`获取原图文件失败: ${asset.fileName}`);
}
}
// 打包原图成zip(保存到本地文件系统)
console.log(`[3D Original] Creating zip from ${originalFiles.length} original files...`);
zipInfo = await createZipFromOriginalFiles(
originalFiles,
materialId!,
material.productModel || 'material'
);
console.log(`[3D Original] Zip created: ${zipInfo.fileName} (${zipInfo.fileSize} bytes)`);
zipFilePath = zipInfo.filePath;
}
// 步骤2: 为所有原图生成预览图并上传到S3(如果前端没有提供预览图)
// 注意:详情页需要显示所有原图的预览版本,所以每张原图都要生成预览图
if (!assets.preview || assets.preview.length === 0) {
console.log(`[AI Original] No preview provided, generating previews for all ${assets.main?.length || 0} original images...`);
if (assets.main && assets.main.length > 0) {
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { readFile } = await import('fs/promises');
// 为每张原图生成预览图
for (let i = 0; i < assets.main.length; i++) {
const original = assets.main[i];
try {
const filePath = await getUploadedFile(original.uploadId);
if (filePath) {
const buffer = await readFile(filePath);
// 压缩并上传预览图
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp',
});
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
uploadedPreviewUrls.push(previewUrl);
console.log(`[AI Original] Generated preview image ${i + 1}/${assets.main.length}:`, previewUrl);
}
} catch (err) {
console.error(`[AI Original] Failed to generate preview for image ${i + 1}:`, err);
// 继续处理其他图片,不中断整个流程
}
}
console.log(`[AI Original] Generated ${uploadedPreviewUrls.length} preview images total`);
}
}
// 准备资产数据
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName?: string;
fileSize?: bigint;
sortOrder?: number;
}> = [];
// S3预览图(前端上传的,或后端生成的)
const previewAssets = assets.preview || [];
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
uploadedPreviewUrls.forEach((url, index) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: url,
fileName: `preview-${index}.webp`,
sortOrder: index,
});
});
} else if (previewAssets.length > 0) {
// 使用前端上传的预览图
previewAssets.forEach((asset: any, index: number) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: asset.fileUrl,
fileName: asset.fileName,
fileSize: BigInt(asset.fileSize),
sortOrder: index,
});
});
}
// 添加zip资产记录
if (zipInfo) {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.ZIP,
fileUrl: zipInfo.filePath, // 本地路径
fileName: zipInfo.fileName,
fileSize: BigInt(zipInfo.fileSize),
sortOrder: 0,
});
}
// 使用事务保存所有资产记录并更新素材信息
await prisma.$transaction(async (tx) => {
// 批量创建资产记录
if (assetRecords.length > 0) {
await tx.materialAsset.createMany({
data: assetRecords,
});
}
// 更新素材记录:设置预览图和压缩包信息
let firstPreview = '';
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
firstPreview = uploadedPreviewUrls[0];
} else if (assets.preview && assets.preview.length > 0) {
// 使用前端上传的预览图
firstPreview = assets.preview[0].fileUrl;
}
await tx.material.update({
where: { id: materialId! },
data: {
previewUrl: firstPreview,
zipFilePath: zipInfo?.filePath || '',
zipFileName: zipInfo?.fileName || '',
zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0),
},
});
});
// 清理临时文件(chunk临时目录和组装的完整文件)
console.log(`[3D Original] Cleaning up ${tempFiles.length} temporary files...`);
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
console.log(`[3D Original] Deleted temp file: ${filePath}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete temp file: ${filePath}`, err);
}
}
// 清理chunk临时目录
if (assets.main && assets.main.length > 0) {
const path = await import('path');
const { rm } = await import('fs/promises');
for (const asset of assets.main) {
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', asset.uploadId);
try {
await rm(tempDir, { recursive: true, force: true });
console.log(`[3D Original] Deleted chunk temp dir: ${tempDir}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete chunk temp dir: ${tempDir}`);
}
}
}
console.log('[3D Original] Complete success!');
return NextResponse.json({
success: true,
materialId,
});
} catch (error: any) {
console.error('[3D Original Complete Error]', error);
// 清理已上传的S3预览图
if (uploadedPreviewUrls.length > 0) {
console.log('[Cleanup] Cleaning up uploaded S3 preview images...');
const { deleteFromS3 } = await import('@/lib/s3');
for (const fileUrl of uploadedPreviewUrls) {
try {
await deleteFromS3(fileUrl);
} catch (cleanupError) {
console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError);
}
}
}
// 清理本地zip文件
if (zipFilePath) {
try {
const { unlink } = await import('fs/promises');
const fullPath = path.join(process.cwd(), zipFilePath);
await unlink(fullPath);
console.log('[Cleanup] Deleted local zip file:', fullPath);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete local zip file:', cleanupError);
}
}
// 清理临时文件
if (tempFiles.length > 0) {
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
} catch (cleanupError) {
console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`);
}
}
}
// 删除素材记录
if (materialId) {
try {
await prisma.material.delete({
where: { id: materialId },
});
console.log('[Cleanup] Deleted material record:', materialId);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete material record:', cleanupError);
}
}
return NextResponse.json(
{ error: error.message || '完成上传失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// POST - 初始化AI原图素材记录
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body;
// 验证必填字段
if (!productModel || !categoryName || !sceneName) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
// 验证任务号格式(如果提供)
if (taskNo) {
const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/;
if (!taskNoRegex.test(taskNo)) {
return NextResponse.json(
{ error: '任务号格式错误' },
{ status: 400 }
);
}
// 检查任务号是否已存在(排除已删除的素材)
const existingMaterial = await prisma.material.findFirst({
where: {
taskNo,
deletedAt: null, // 只检查未删除的素材
},
select: { id: true, productModel: true, materialType: true },
});
if (existingMaterial) {
return NextResponse.json(
{ error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` },
{ status: 400 }
);
}
}
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: (session.user as any).id },
select: { username: true },
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
// 创建素材记录(使用临时预览图)
const material = await prisma.material.create({
data: {
materialType: '任务-AI原图',
productModel,
categoryName,
sceneName,
platformName: platformName || '',
productCategoryName: productCategoryName || '',
authorName: user.username,
userId: (session.user as any).id,
previewUrl: '', // 临时空值,完成上传时更新
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
renderer: '', // AI原图不需要渲染器
taskNo: taskNo || null, // 任务号(可选)
},
});
return NextResponse.json({
success: true,
materialId: material.id,
});
} catch (error: any) {
console.error('[AI Original Init Error]', error);
return NextResponse.json(
{ error: error.message || '初始化失败' },
{ status: 500 }
);
}
}

View File

@ -91,7 +91,7 @@ export async function GET(req: NextRequest) {
}
// 检查是否为管理员(可选,如果需要限制权限)
// const user = await prisma.user.findUnique({ where: { id: session.user.id } })
// const user = await prisma.user.findUnique({ where: { id: (session.user as any).id } })
// if (user?.role !== 'SUPER_ADMIN') {
// return NextResponse.json({ error: '权限不足' }, { status: 403 })
// }

View File

@ -1,5 +1,5 @@
/**
* 产品全案上传完成接口
* 任务-详情上传完成接口
* POST - 保存资产信息,更新任务状态
*/
@ -30,11 +30,12 @@ interface CompleteRequestBody {
export async function POST(request: NextRequest) {
let materialId: string | null = null;
let uploadedFileUrls: string[] = [];
let taskNo: string | undefined = undefined;
try {
// 验证用户登录
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -42,7 +43,8 @@ export async function POST(request: NextRequest) {
}
const body: CompleteRequestBody = await request.json();
const { materialId: bodyMaterialId, taskNo, assets } = body;
const { materialId: bodyMaterialId, taskNo: bodyTaskNo, assets } = body;
taskNo = bodyTaskNo;
// 验证必填字段(taskNo改为可选,不提示)
if (!bodyMaterialId || !assets) {
@ -55,10 +57,10 @@ export async function POST(request: NextRequest) {
// 如果提供了任务号,验证格式和唯一性
if (taskNo && taskNo.trim()) {
// 验证任务号格式(双重验证,防止前端传递错误)
const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package');
const { validateTaskNo, getExistingMaterialByTaskNo } = await import('@/lib/product-package');
if (!validateTaskNo(taskNo)) {
return NextResponse.json(
{ success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' },
{ success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' },
{ status: 400 }
);
}
@ -70,12 +72,12 @@ export async function POST(request: NextRequest) {
deletedAt: null,
id: { not: bodyMaterialId }, // 排除当前素材
},
select: { id: true },
select: { id: true, productModel: true, materialType: true },
});
if (existingMaterial) {
return NextResponse.json(
{ success: false, error: '任务号已被其他素材使用' },
{ success: false, error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` },
{ status: 409 }
);
}
@ -101,7 +103,7 @@ export async function POST(request: NextRequest) {
);
}
if (material.userId !== session.user.id) {
if (material.userId !== (session.user as any).id) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
@ -244,7 +246,7 @@ export async function POST(request: NextRequest) {
await prisma.materialLog.create({
data: {
materialId: materialId!,
userId: session.user.id,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: JSON.stringify({
...(taskNo && taskNo.trim() ? { taskNo: taskNo } : {}), // 只有提供任务号时才记录
@ -268,6 +270,12 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error('[product-package/complete] error:', error);
console.error('[product-package/complete] error details:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
materialId,
taskNo,
});
// 如果失败,尝试清理已上传的文件,避免产生垃圾文件
if (materialId) {
@ -281,8 +289,10 @@ export async function POST(request: NextRequest) {
}
}
// 返回更详细的错误信息
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ success: false, error: errorMessage || '服务器错误' },
{ status: 500 }
);
}

View File

@ -1,5 +1,5 @@
/**
* 产品全案上传初始化接口
* 任务-详情上传初始化接口
* POST - 创建素材记录,返回上传配置
*/
@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
try {
// 验证用户登录
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { taskNo, productModel, categoryName, sceneName } = body;
const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body;
// 验证必填字段(任务号改为可选)
if (!productModel || !categoryName) {
@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
// 验证任务号格式
if (!validateTaskNo(taskNo)) {
return NextResponse.json(
{ success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' },
{ success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' },
{ status: 400 }
);
}
@ -97,11 +97,13 @@ export async function POST(request: NextRequest) {
productModel,
categoryName,
sceneName: sceneName || '',
materialType: '产品全案',
platformName: platformName || '',
productCategoryName: productCategoryName || '',
materialType: '任务-详情', // 产品全案/任务-详情
// taskStatus: 不写入,上传完成后再写入
previewUrl: '', // 临时占位,上传完成后更新
authorName: (session.user as any).username || (session.user as any).realName || session.user.email,
userId: session.user.id,
userId: (session.user as any).id,
renderer: '', // 产品全案不需要渲染器
} as any,
});

View File

@ -0,0 +1,413 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { unlink, mkdir, writeFile } from 'fs/promises';
import path from 'path';
import archiver from 'archiver';
import { createWriteStream } from 'fs';
import { compressImage } from '@/lib/image-processor';
// 资产类型常量
const ASSET_TYPE = {
PREVIEW: 'preview',
ZIP: 'zip',
} as const;
// 生成zip压缩包(从chunk临时目录的原图文件打包)
async function createZipFromOriginalFiles(
originalFiles: Array<{ path: string; name: string; size: number }>,
materialId: string,
productModel: string
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
// 创建目标目录
const uploadsDir = path.join(process.cwd(), 'uploads', 'materials');
await mkdir(uploadsDir, { recursive: true });
// 生成zip文件名和路径
const timestamp = Date.now();
const zipFileName = `${productModel}_${materialId}_${timestamp}.zip`;
const zipFilePath = path.join(uploadsDir, zipFileName);
const relativeZipPath = path.join('uploads', 'materials', zipFileName);
console.log('[Zip] Creating zip file:', zipFilePath);
console.log('[Zip] Original files count:', originalFiles.length);
return new Promise((resolve, reject) => {
try {
// 创建输出流
const output = createWriteStream(zipFilePath);
const archive = archiver('zip', {
zlib: { level: 9 } // 最高压缩级别
});
// 监听完成事件
output.on('close', () => {
console.log('[Zip] Zip file created successfully');
console.log('[Zip] Total bytes:', archive.pointer());
resolve({
filePath: relativeZipPath,
fileName: zipFileName,
fileSize: archive.pointer()
});
});
// 监听错误
archive.on('error', (err) => {
console.error('[Zip] Archive error:', err);
reject(err);
});
output.on('error', (err) => {
console.error('[Zip] Output stream error:', err);
reject(err);
});
// 连接archive到输出流
archive.pipe(output);
// 添加每个原图到zip(直接从chunk临时文件添加)
for (const file of originalFiles) {
console.log(`[Zip] Adding file: ${file.name} (${file.size} bytes)`);
archive.file(file.path, { name: file.name });
}
// 完成压缩
archive.finalize();
} catch (error) {
console.error('[Zip] Error:', error);
reject(error);
}
});
}
// 压缩原图生成预览图并上传到S3
async function compressAndUploadPreview(
originalFile: File,
materialId: string
): Promise<string> {
const buffer = Buffer.from(await originalFile.arrayBuffer());
// 压缩图片
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp'
});
// 生成文件名
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
// 上传到S3
const url = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
return url;
}
// POST - 完成3D原图上传
export async function POST(request: NextRequest) {
let materialId: string | null = null;
const tempFiles: string[] = []; // 需要清理的临时文件路径
let zipFilePath: string | null = null;
const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { materialId: matId, assets } = body;
materialId = matId;
if (!materialId || !assets) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
console.log('[3D Original Complete] Starting, materialId:', materialId);
console.log('[3D Original Complete] Preview assets (S3):', assets.preview?.length || 0);
console.log('[3D Original Complete] Main assets (uploadIds):', assets.main?.length || 0);
// 验证素材是否存在且属于当前用户
const material: any = await prisma.material.findUnique({
where: { id: materialId },
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (material.userId !== (session.user as any).id) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
);
}
// 步骤1: 从chunk临时目录读取原图文件并打包成zip
let zipInfo = null;
if (assets.main && assets.main.length > 0) {
console.log(`[3D Original] Processing ${assets.main.length} original images from chunk uploads...`);
// 从chunk临时目录读取完整的原图文件
const { getUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
originalFiles.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
});
tempFiles.push(filePath); // 记录需要清理的临时文件
} else {
throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`);
}
} catch (error) {
console.error(`[3D Original] Failed to get file for uploadId ${asset.uploadId}:`, error);
throw new Error(`获取原图文件失败: ${asset.fileName}`);
}
}
// 打包原图成zip(保存到本地文件系统)
console.log(`[3D Original] Creating zip from ${originalFiles.length} original files...`);
zipInfo = await createZipFromOriginalFiles(
originalFiles,
materialId!,
material.productModel || 'material'
);
console.log(`[3D Original] Zip created: ${zipInfo.fileName} (${zipInfo.fileSize} bytes)`);
zipFilePath = zipInfo.filePath;
}
// 步骤2: 为所有原图生成预览图并上传到S3(如果前端没有提供预览图)
// 注意:详情页需要显示所有原图的预览版本,所以每张原图都要生成预览图
if (!assets.preview || assets.preview.length === 0) {
console.log(`[Real Photo] No preview provided, generating previews for all ${assets.main?.length || 0} original images...`);
if (assets.main && assets.main.length > 0) {
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { readFile } = await import('fs/promises');
// 为每张原图生成预览图
for (let i = 0; i < assets.main.length; i++) {
const original = assets.main[i];
try {
const filePath = await getUploadedFile(original.uploadId);
if (filePath) {
const buffer = await readFile(filePath);
// 压缩并上传预览图
const compressedBuffer = await compressImage(buffer, {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
format: 'webp',
});
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${randomSuffix}.webp`;
const s3Path = `product-package/${fileName}`;
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
uploadedPreviewUrls.push(previewUrl);
console.log(`[Real Photo] Generated preview image ${i + 1}/${assets.main.length}:`, previewUrl);
}
} catch (err) {
console.error(`[Real Photo] Failed to generate preview for image ${i + 1}:`, err);
// 继续处理其他图片,不中断整个流程
}
}
console.log(`[Real Photo] Generated ${uploadedPreviewUrls.length} preview images total`);
}
}
// 准备资产数据
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName?: string;
fileSize?: bigint;
sortOrder?: number;
}> = [];
// S3预览图(前端上传的,或后端生成的)
const previewAssets = assets.preview || [];
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
uploadedPreviewUrls.forEach((url, index) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: url,
fileName: `preview-${index}.webp`,
sortOrder: index,
});
});
} else if (previewAssets.length > 0) {
// 使用前端上传的预览图
previewAssets.forEach((asset: any, index: number) => {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: asset.fileUrl,
fileName: asset.fileName,
fileSize: BigInt(asset.fileSize),
sortOrder: index,
});
});
}
// 添加zip资产记录
if (zipInfo) {
assetRecords.push({
materialId: materialId!,
assetType: ASSET_TYPE.ZIP,
fileUrl: zipInfo.filePath, // 本地路径
fileName: zipInfo.fileName,
fileSize: BigInt(zipInfo.fileSize),
sortOrder: 0,
});
}
// 使用事务保存所有资产记录并更新素材信息
await prisma.$transaction(async (tx) => {
// 批量创建资产记录
if (assetRecords.length > 0) {
await tx.materialAsset.createMany({
data: assetRecords,
});
}
// 更新素材记录:设置预览图和压缩包信息
let firstPreview = '';
if (uploadedPreviewUrls.length > 0) {
// 使用后端生成的预览图
firstPreview = uploadedPreviewUrls[0];
} else if (assets.preview && assets.preview.length > 0) {
// 使用前端上传的预览图
firstPreview = assets.preview[0].fileUrl;
}
await tx.material.update({
where: { id: materialId! },
data: {
previewUrl: firstPreview,
zipFilePath: zipInfo?.filePath || '',
zipFileName: zipInfo?.fileName || '',
zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0),
},
});
});
// 清理临时文件(chunk临时目录和组装的完整文件)
console.log(`[3D Original] Cleaning up ${tempFiles.length} temporary files...`);
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
console.log(`[3D Original] Deleted temp file: ${filePath}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete temp file: ${filePath}`, err);
}
}
// 清理chunk临时目录
if (assets.main && assets.main.length > 0) {
const path = await import('path');
const { rm } = await import('fs/promises');
for (const asset of assets.main) {
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', asset.uploadId);
try {
await rm(tempDir, { recursive: true, force: true });
console.log(`[3D Original] Deleted chunk temp dir: ${tempDir}`);
} catch (err) {
console.warn(`[3D Original] Failed to delete chunk temp dir: ${tempDir}`);
}
}
}
console.log('[3D Original] Complete success!');
return NextResponse.json({
success: true,
materialId,
});
} catch (error: any) {
console.error('[3D Original Complete Error]', error);
// 清理已上传的S3预览图
if (uploadedPreviewUrls.length > 0) {
console.log('[Cleanup] Cleaning up uploaded S3 preview images...');
const { deleteFromS3 } = await import('@/lib/s3');
for (const fileUrl of uploadedPreviewUrls) {
try {
await deleteFromS3(fileUrl);
} catch (cleanupError) {
console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError);
}
}
}
// 清理本地zip文件
if (zipFilePath) {
try {
const { unlink } = await import('fs/promises');
const fullPath = path.join(process.cwd(), zipFilePath);
await unlink(fullPath);
console.log('[Cleanup] Deleted local zip file:', fullPath);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete local zip file:', cleanupError);
}
}
// 清理临时文件
if (tempFiles.length > 0) {
const { unlink } = await import('fs/promises');
for (const filePath of tempFiles) {
try {
await unlink(filePath);
} catch (cleanupError) {
console.warn(`[Cleanup] Failed to delete temp file: ${filePath}`);
}
}
}
// 删除素材记录
if (materialId) {
try {
await prisma.material.delete({
where: { id: materialId },
});
console.log('[Cleanup] Deleted material record:', materialId);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete material record:', cleanupError);
}
}
return NextResponse.json(
{ error: error.message || '完成上传失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// POST - 初始化实拍原图素材记录
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json();
const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body;
// 验证必填字段
if (!productModel || !categoryName || !sceneName) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
);
}
// 验证任务号格式(如果提供)
if (taskNo) {
const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/;
if (!taskNoRegex.test(taskNo)) {
return NextResponse.json(
{ error: '任务号格式错误' },
{ status: 400 }
);
}
// 检查任务号是否已存在(排除已删除的素材)
const existingMaterial = await prisma.material.findFirst({
where: {
taskNo,
deletedAt: null, // 只检查未删除的素材
},
select: { id: true, productModel: true, materialType: true },
});
if (existingMaterial) {
return NextResponse.json(
{ error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` },
{ status: 400 }
);
}
}
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: (session.user as any).id },
select: { username: true },
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
// 创建素材记录(使用临时预览图)
const material = await prisma.material.create({
data: {
materialType: '任务-实拍原图',
productModel,
categoryName,
sceneName,
platformName: platformName || '',
productCategoryName: productCategoryName || '',
authorName: user.username,
userId: (session.user as any).id,
previewUrl: '', // 临时空值,完成上传时更新
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
renderer: '', // 实拍原图不需要渲染器
taskNo: taskNo || null, // 任务号(可选)
},
});
return NextResponse.json({
success: true,
materialId: material.id,
});
} catch (error: any) {
console.error('[Real Photo Init Error]', error);
return NextResponse.json(
{ error: error.message || '初始化失败' },
{ status: 500 }
);
}
}

View File

@ -96,18 +96,19 @@ export async function POST(request: NextRequest) {
// 任务号验证(可选,如果提供则验证格式和唯一性)
if (taskNo && taskNo.trim()) {
const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package');
const { validateTaskNo, getExistingMaterialByTaskNo } = await import('@/lib/product-package');
if (!validateTaskNo(taskNo)) {
return NextResponse.json(
{ error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' },
{ error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' },
{ status: 400 }
);
}
// 检查任务号是否已存在
if (await isTaskNoExists(taskNo)) {
// 检查任务号是否已存在,返回友好提示
const existingMaterial = await getExistingMaterialByTaskNo(taskNo);
if (existingMaterial) {
return NextResponse.json(
{ error: '任务号已存在,请使用其他任务号' },
{ error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` },
{ status: 400 }
);
}
@ -132,7 +133,7 @@ export async function POST(request: NextRequest) {
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: (session.user as any).id },
select: { username: true },
});
@ -505,7 +506,7 @@ export async function POST(request: NextRequest) {
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
// taskNo: 不写入,上传完成后再写入
// taskStatus: 不写入,上传完成后再写入
userId: session.user.id,
userId: (session.user as any).id,
} as any,
include: {
user: {

View File

@ -5,7 +5,7 @@ import { promisify } from 'util'
import { uploadSessions } from '@/lib/upload-sessions'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { prisma } from '@/lib/prisma'
import { getNetworkSpeed, calculateUploadSpeedFromSessions } from '@/lib/network-bandwidth-monitor'
import { getNetworkSpeed } from '@/lib/network-bandwidth-monitor'
const execAsync = promisify(exec)
@ -108,12 +108,11 @@ export async function GET() {
// 获取网络带宽使用情况
const networkSpeed = await getNetworkSpeed()
const uploadSpeedFromSessions = calculateUploadSpeedFromSessions(uploadSessions)
// 优先使用系统级别的网络统计,如果没有则使用上传会话的速度
const network = {
uploadSpeed: networkSpeed?.uploadSpeed || uploadSpeedFromSessions || 0, // 字节/秒
uploadSpeed: networkSpeed?.uploadSpeed || 0, // 字节/秒
downloadSpeed: networkSpeed?.downloadSpeed || 0, // 字节/秒
interface: networkSpeed?.interface || 'unknown', // 使用的网卡
}
// 2. 上传队列(基于 uploadSessions)

View File

@ -56,7 +56,14 @@ export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
@ -103,7 +110,14 @@ export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
@ -173,7 +187,14 @@ export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }

View File

@ -68,7 +68,14 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
@ -122,7 +129,14 @@ export async function PUT(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}

View File

@ -101,7 +101,14 @@ export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
@ -159,7 +166,14 @@ export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}

View File

@ -44,7 +44,14 @@ export async function GET() {
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || !isSystemAdmin(session.user.role)) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (!isSystemAdmin(userRole)) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -9,7 +9,14 @@ import { uploadToS3 } from '@/lib/s3';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ error: '未登录' },
{ status: 401 }
);
}
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { access, stat } from 'fs/promises';
import path from 'path';
import { createDownloadError, DownloadErrorCode } from '@/lib/download-errors';
/**
* 测试下载接口(绕过登录检查)
* 仅用于开发和测试,生产环境应禁用
*/
export async function GET(request: NextRequest) {
// 检查是否是测试环境
if (process.env.NODE_ENV === 'production') {
return NextResponse.json(
{ error: '此接口仅用于测试' },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const materialId = searchParams.get('id');
if (!materialId) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'),
{ status: 400 }
);
}
try {
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
productModel: true,
zipFilePath: true,
materialType: true,
},
});
if (!material) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.MATERIAL_NOT_FOUND),
{ status: 404 }
);
}
// 检查文件是否存在
if (!material.zipFilePath) {
console.log('[Test Download] No zipFilePath for material:', material.id);
return NextResponse.json(
createDownloadError(DownloadErrorCode.FILE_MISSING, '没有源文件'),
{ status: 404 }
);
}
const filePath = path.join(process.cwd(), material.zipFilePath);
try {
await access(filePath);
const fileStats = await stat(filePath);
return NextResponse.json({
message: '文件存在',
materialId: material.id,
productModel: material.productModel,
filePath: material.zipFilePath,
fileSize: fileStats.size,
});
} catch (error) {
console.error('[Test Download] File not found:', filePath, 'error:', error);
return NextResponse.json(
createDownloadError(DownloadErrorCode.FILE_MISSING, '没有源文件'),
{ status: 404 }
);
}
} catch (error) {
console.error('[Test Download] Error:', error);
return NextResponse.json(
{ error: '查询失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadToS3 } from '@/lib/s3';
/**
* POST - 上传原图到 S3(不压缩)
* 专门用于上传需要保留原始质量的图片(如任务原图)
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
const type = formData.get('type') as string; // original-main/original-preview等
if (!file) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 });
}
// 文件大小检查(原图最大100MB)
if (file.size > 100 * 1024 * 1024) {
return NextResponse.json(
{ error: `文件过大,请使用小于100MB的图片` },
{ status: 400 }
);
}
console.log(`[S3 Original Upload] ========== Processing original file ==========`);
console.log(`[S3 Original Upload] Type: ${type}`);
console.log(`[S3 Original Upload] Original fileName: ${file.name}`);
console.log(`[S3 Original Upload] File size: ${file.size} bytes (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
console.log(`[S3 Original Upload] ⚠️ NO COMPRESSION - Uploading as-is`);
// 读取文件buffer(不压缩)
const buffer = Buffer.from(await file.arrayBuffer());
// 生成文件路径(保持原文件扩展名)
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const extension = file.name.split('.').pop() || 'jpg';
const fileName = `${type}-${timestamp}-${randomSuffix}.${extension}`;
const s3Path = `product-package/${fileName}`;
console.log(`[S3 Original Upload] Generated fileName: ${fileName}`);
console.log(`[S3 Original Upload] S3 path: ${s3Path}`);
// 直接上传原图到 S3(不压缩)
const url = await uploadToS3(buffer, s3Path, file.type || 'image/jpeg');
console.log(`[S3 Original Upload] Uploaded successfully: url=${url}`);
console.log(`[S3 Original Upload] ==========================================`);
return NextResponse.json({ url });
} catch (error) {
console.error('[S3 Original Upload] Error:', error);
return NextResponse.json(
{ error: '上传失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ status: 500 }
);
}
}

View File

@ -11,7 +11,7 @@ import path from 'path';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const userId = session?.user ? (session.user as any).id : undefined;
console.log('[UserAvatar] Session:', { userId, hasSession: !!session, hasUser: !!session?.user });

View File

@ -36,11 +36,11 @@ export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
const userId = (session.user as any).id;
// 查询收藏记录,包含素材信息
const favorites = await prisma.materialFavorite.findMany({

View File

@ -33,11 +33,11 @@ export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
const userId = (session.user as any).id;
const materials = await prisma.material.findMany({
where: {
@ -95,11 +95,11 @@ export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
const userId = (session.user as any).id;
const materials = await prisma.material.findMany({
where: {

View File

@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const userId = session?.user ? (session.user as any).id : undefined;
if (!userId) {
return NextResponse.json({ error: '未登录' }, { status: 401 });

View File

@ -73,26 +73,35 @@ export default function Home() {
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠
// 素材类型列表(已分类排序)
const DEFAULT_MATERIAL_TYPES = [
// 3D相关
'3D场景',
'材质',
'标准模型',
'标准饰品',
// 图片相关
'详情页',
'详情模板',
'材质',
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
// 视频相关
'主图视频',
'安装视频',
'种草视频',
// 模板和种草
'主图模版',
'SKU模版',
'小红书种草',
// 其他
'音乐',
'C4D模型',
'标准饰品',
'标准模型',
'品牌VI灵感',
'矢量文件',
// 任务相关
'任务-详情',
'任务-3D原图',
'任务-AI原图',
'任务-实拍原图',
]
const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string }[]>([])
const [isTransitionLoading, setIsTransitionLoading] = useState(false)

View File

@ -64,8 +64,8 @@ export default function RegisterPage() {
if (response.ok) {
router.push('/login?registered=true')
} else {
const parsedError = await parseFetchError(response)
setError(parsedError.message)
// 直接使用已解析的data,不要再次调用parseFetchError
setError(data.error || '注册失败,请稍后再试')
}
} catch (error) {
console.error('Registration error:', error)

View File

@ -13,6 +13,7 @@ import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import AudioVisualizer from '@/components/AudioVisualizer'
import ProductPackageDetail from '@/components/detail/ProductPackageDetail'
import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail'
import { parseDownloadError } from '@/lib/download-errors'
import { parseApiError, parseFetchError } from '@/lib/error-handler'
@ -549,7 +550,7 @@ export default function SharePage() {
<Header session={session} />
<main className="container mx-auto px-4 py-8">
{/* 产品全案类型:使用专用详情组件 */}
{material.materialType === '产品全案' ? (
{material.materialType === '任务-详情' ? (
<div className="max-w-7xl mx-auto">
<ProductPackageDetail
material={{
@ -629,6 +630,71 @@ export default function SharePage() {
onBack={() => router.back()}
/>
</div>
) : material.materialType === '任务-3D原图' || material.materialType === '任务-AI原图' || material.materialType === '任务-实拍原图' ? (
/* 3D原图类型:使用专用详情组件 */
<div className="max-w-7xl mx-auto">
<ThreeDOriginalDetail
material={{
id: material.id,
taskNo: material.taskNo || '',
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
taskStatus: material.taskStatus || 'pending',
taskFetchedAt: material.taskFetchedAt,
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false}
showToast={showToast}
onDownload={async (type, assetId) => {
// 处理下载,使用与ProductPackageDetail相同的下载API
let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`;
if (assetId) {
downloadUrl += `&assetId=${assetId}`;
}
try {
const response = await fetch(downloadUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: '下载失败' }));
const { message } = parseDownloadError(errorData);
throw new Error(message);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 从响应头获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
a.download = fileName;
} else {
a.download = `${material.productModel}_source.zip`;
}
} else {
a.download = `${material.productModel}_source.zip`;
}
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
return Promise.resolve();
} catch (error) {
console.error('下载失败:', error);
const { message } = parseDownloadError(error);
showToast(message, { variant: 'error' });
return Promise.reject(error);
}
}}
onBack={() => router.back()}
/>
</div>
) : (
/* 其他素材类型:使用原有左右布局 */
<>

73
check-truenas-iscsi.sh Normal file
View File

@ -0,0 +1,73 @@
#!/bin/bash
# 检查TrueNAS是否支持iSCSI的脚本
echo "=========================================="
echo "TrueNAS iSCSI支持检查"
echo "=========================================="
echo ""
# 检查iSCSI服务
echo "🔍 检查iSCSI服务..."
if systemctl list-units | grep -q "iscsi"; then
echo "✅ 找到iSCSI相关服务"
systemctl list-units | grep iscsi
else
echo "❌ 未找到iSCSI服务"
fi
echo ""
# 检查iSCSI工具
echo "🔍 检查iSCSI工具..."
if command -v targetcli &> /dev/null; then
echo "✅ targetcli可用"
targetcli --version 2>&1 | head -1
elif command -v tgtadm &> /dev/null; then
echo "✅ tgtadm可用"
tgtadm --version 2>&1 | head -1
else
echo "❌ 未找到iSCSI工具"
fi
echo ""
# 检查iSCSI配置目录
echo "🔍 检查iSCSI配置..."
if [ -d "/etc/tgt" ] || [ -d "/etc/target" ] || [ -d "/sys/kernel/config/target" ]; then
echo "✅ 找到iSCSI配置目录"
ls -la /etc/tgt /etc/target /sys/kernel/config/target 2>/dev/null | head -10
else
echo "❌ 未找到iSCSI配置目录"
fi
echo ""
# 检查middleware配置
echo "🔍 检查TrueNAS middleware配置..."
if [ -d "/usr/share/truenas/middleware" ]; then
echo "✅ 找到middleware目录"
if find /usr/share/truenas/middleware -name "*iscsi*" 2>/dev/null | head -5; then
echo "✅ 找到iSCSI相关配置"
else
echo "⚠️ 未找到iSCSI相关配置"
fi
else
echo "❌ 未找到middleware目录"
fi
echo ""
# 检查Web界面API
echo "🔍 检查TrueNAS API..."
if curl -k -s -u root:$(cat /etc/freenas-v1.db 2>/dev/null | grep -i password | head -1 | cut -d: -f2) https://localhost/api/v2.0/iscsi 2>&1 | grep -q "iscsi"; then
echo "✅ iSCSI API可用"
else
echo "⚠️ 无法通过API检查(需要Web界面确认)"
fi
echo ""
echo "=========================================="
echo "检查完成"
echo "=========================================="
echo ""
echo "💡 建议:"
echo " 1. 登录TrueNAS Web界面"
echo " 2. 查看 Sharing → Block Shares (iSCSI)"
echo " 3. 如果存在iSCSI选项,说明支持iSCSI"
echo ""

100
check-zfs-snapshots.sh Normal file
View File

@ -0,0 +1,100 @@
#!/bin/bash
# 检查ZFS快照脚本
# 在TrueNAS系统上运行
set -e
DATASET="PoolOne/apps/youyidian-3d"
TARGET_DIR="/mnt/PoolOne/apps/youyidian-3d/uploads"
echo "=========================================="
echo "ZFS快照检查脚本"
echo "=========================================="
echo ""
echo "目标数据集: $DATASET"
echo "目标目录: $TARGET_DIR"
echo ""
# 检查数据集是否存在
if ! zfs list "$DATASET" >/dev/null 2>&1; then
echo "❌ 错误: 数据集 $DATASET 不存在"
echo ""
echo "可用数据集列表:"
zfs list -o name | grep -E "PoolOne/apps" | head -10
exit 1
fi
echo "✅ 数据集存在"
echo ""
# 检查快照
echo "🔍 检查快照..."
echo ""
SNAPSHOTS=$(zfs list -t snapshot -o name,creation,used "$DATASET" 2>/dev/null | tail -n +2)
if [ -z "$SNAPSHOTS" ]; then
echo "❌ 未找到快照"
echo ""
echo "💡 建议:"
echo " 1. 检查TrueNAS管理界面中的快照设置"
echo " 2. 考虑从备份文件恢复"
echo " 3. 检查是否有其他数据集的快照"
else
echo "✅ 找到以下快照:"
echo ""
echo "$SNAPSHOTS" | while read -r line; do
echo " $line"
done
echo ""
# 查找1月13日之前的快照
echo "🔍 查找1月13日09:15之前的快照..."
RECENT_SNAPSHOTS=$(echo "$SNAPSHOTS" | awk '$2 <= "2026-01-13 09:15" {print $1}' | tail -5)
if [ -n "$RECENT_SNAPSHOTS" ]; then
echo "✅ 找到相关快照:"
echo ""
echo "$RECENT_SNAPSHOTS" | while read -r snap; do
echo " - $snap"
done
echo ""
echo "💡 可以使用以下命令恢复:"
echo " zfs clone <快照名> PoolOne/apps/youyidian-3d-recovered"
echo " 或"
echo " zfs rollback <快照名>"
else
echo "⚠️ 未找到1月13日09:15之前的快照"
fi
fi
echo ""
echo "=========================================="
echo "检查父数据集的快照..."
echo "=========================================="
echo ""
# 检查父数据集的快照
PARENT_DATASETS=(
"PoolOne/apps"
"PoolOne"
)
for parent in "${PARENT_DATASETS[@]}"; do
if zfs list "$parent" >/dev/null 2>&1; then
echo "检查 $parent 的快照..."
PARENT_SNAPSHOTS=$(zfs list -t snapshot -o name,creation "$parent" 2>/dev/null | tail -n +2 | head -10)
if [ -n "$PARENT_SNAPSHOTS" ]; then
echo "$PARENT_SNAPSHOTS" | while read -r line; do
echo " $line"
done
echo ""
else
echo " 未找到快照"
echo ""
fi
fi
done
echo "=========================================="
echo "检查完成"
echo "=========================================="

View File

@ -0,0 +1,638 @@
# 数据恢复完整执行计划(详细版)
## 执行目标
恢复 `/mnt/PoolOne/apps/youyidian-3d/uploads` 目录中被删除的数据。
## 当前状态确认(已验证)
### ✅ 存储池信息
- **存储池名称**: PoolOne
- **配置类型**: raidz2(7块硬盘,nparity=2)
- **存储池状态**: ONLINE(正常)
- **存储池GUID**: 162731025200795572
- **当前事务组ID**: 27532120
- **创建时间**: 2021-07-12 16:46
- **总容量**: 约50.5TB(55995876507648字节)
- **已使用**: 约7.96TB
- **可用空间**: 约25.3TB
- **挂载点**: /mnt/PoolOne
- **ZFS版本**: 5000
### ✅ 硬盘信息(已确认)
7块 WDC WD80EFBX 8TB 硬盘(全部ONLINE):
1. **GUID**: 9731067280533246126
- 路径: `/dev/disk/by-id/ata-WDC_WD80EFBX-68AZZN0_VRGYN7GK-part2`
- 物理路径: `pci-0000:06:00.0-sas-phy2-lun-0`
2. **GUID**: 11121246498498955823
- 路径: `/dev/disk/by-id/ata-WDC_WD80EFBX-68AZZN0_VRGZW7VK-part2`
- 物理路径: `pci-0000:06:00.0-sas-phy3-lun-0`
3. **GUID**: 16408888224501682354
- 路径: `/dev/disk/by-id/ata-WDC_WD80EFBX-68AZZN0_VRGZT7LK-part2`
4-7. (其他4块硬盘,详细信息可从zdb获取)
### ✅ uploads目录状态(已确认)
- **路径**: `/mnt/PoolOne/apps/youyidian-3d/uploads`
- **状态**: 目录存在但为空
- **大小**: 2字节(仅目录结构)
- **Inode**: 88659
- **最后修改时间**: 2026-01-13 09:15:19(与数据丢失时间一致)
- **最后访问时间**: 2026-01-13 09:26:18
- **权限**: 0755 (drwxr-xr-x)
- **所有者**: root:root
### ✅ ZFS特性(已确认)
- feature@async_destroy: enabled
- feature@lz4_compress: active
- feature@embedded_data: active
- 其他标准特性已启用
## 恢复方案详细执行步骤
## 方案1:Ubuntu设备 + ZFS存储池导入(优先级最高)⭐⭐⭐⭐⭐
### 阶段1:环境准备(预估时间:30分钟)
#### 步骤1.1:Ubuntu设备准备
**检查清单**:
- [ ] Ubuntu设备型号:_____________
- [ ] Ubuntu版本:_____________(需要20.04或更高)
- [ ] 可用SATA接口数量:_____________(需要至少7个)
- [ ] 可用USB 3.0接口数量:_____________(如果使用USB硬盘盒)
- [ ] 电源容量:_____________(7块8TB硬盘需要至少100W)
- [ ] 可用存储空间:_____________(需要至少100GB)
**验证命令**:
```bash
# 检查Ubuntu版本
lsb_release -a
# 要求:Ubuntu 20.04或更高版本
# 检查可用空间
df -h /
# 要求:至少100GB可用空间
# 检查SATA接口(如果使用SATA连接)
lspci | grep -i sata
# 或
dmesg | grep -i sata
```
#### 步骤1.2:软件安装
**执行命令**:
```bash
# 1. 更新系统
sudo apt-get update
sudo apt-get upgrade -y
# 2. 安装ZFS工具
sudo apt-get install -y zfsutils-linux
# 3. 验证ZFS安装
zfs --version
zpool --version
# 预期输出:ZFS版本信息,应该与TrueNAS的ZFS版本兼容
# 4. 安装数据恢复工具
sudo apt-get install -y testdisk photorec foremost scalpel
# 5. 验证工具安装
testdisk --version
photorec --version
# 预期输出:工具版本信息
# 6. 创建恢复工作目录
RECOVERY_DIR="/tmp/recovery_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$RECOVERY_DIR"
echo "恢复目录: $RECOVERY_DIR"
echo "$RECOVERY_DIR" > /tmp/recovery_dir.txt
```
**验证点**:
- [ ] ZFS工具安装成功
- [ ] 数据恢复工具安装成功
- [ ] 恢复目录创建成功
### 阶段2:硬盘连接和识别(预估时间:1-2小时)
#### 步骤2.1:硬件连接
**选项A:直接SATA连接(推荐,如果可能)**
- 步骤:
1. 关闭Ubuntu设备
2. 将7块硬盘连接到SATA接口
3. 确保所有硬盘电源连接
4. 开机
**选项B:USB 3.0硬盘盒(备用方案)**
- 步骤:
1. 使用USB 3.0多盘位硬盘盒(推荐)
2. 或使用多个单盘位USB硬盘盒
3. 连接到Ubuntu设备的USB 3.0接口
4. 确保电源充足
**注意事项**:
- ⚠️ 必须连接所有7块硬盘
- ⚠️ 确保电源充足(每块硬盘约10-15W)
- ⚠️ 使用USB 3.0接口(速度要求)
#### 步骤2.2:硬盘识别和验证
**执行命令**:
```bash
# 1. 查看所有块设备
lsblk > "$RECOVERY_DIR/lsblk-initial.txt"
cat "$RECOVERY_DIR/lsblk-initial.txt"
# 2. 查看硬盘ID(匹配TrueNAS中的硬盘)
ls -la /dev/disk/by-id/ | grep WDC_WD80EFBX > "$RECOVERY_DIR/disk-id-list.txt"
cat "$RECOVERY_DIR/disk-id-list.txt"
# 3. 统计匹配的硬盘数量
DISK_COUNT=$(grep -c "WDC_WD80EFBX.*part2" "$RECOVERY_DIR/disk-id-list.txt")
echo "找到硬盘数量: $DISK_COUNT"
# 4. 验证所有7块硬盘
if [ "$DISK_COUNT" -eq 7 ]; then
echo "✅ 所有7块硬盘已连接"
echo "✅" > "$RECOVERY_DIR/disk-check-ok.txt"
else
echo "❌ 错误: 只找到 $DISK_COUNT 块硬盘,需要7块"
echo "❌" > "$RECOVERY_DIR/disk-check-ok.txt"
exit 1
fi
# 5. 记录设备映射关系
echo "=== 硬盘设备映射 ===" > "$RECOVERY_DIR/disk-mapping.txt"
grep "WDC_WD80EFBX.*part2" "$RECOVERY_DIR/disk-id-list.txt" | while read line; do
DISK_ID=$(echo "$line" | awk '{print $9}')
DEVICE=$(echo "$line" | awk '{print $11}' | sed 's|../||')
echo "$DISK_ID -> $DEVICE" >> "$RECOVERY_DIR/disk-mapping.txt"
done
cat "$RECOVERY_DIR/disk-mapping.txt"
```
**验证点**:
- [ ] 所有7块硬盘都被识别
- [ ] 硬盘ID匹配TrueNAS中的ID
- [ ] 设备映射关系已记录
#### 步骤2.3:硬盘状态检查
**执行命令**:
```bash
# 检查每块硬盘的状态
for disk_id in $(grep "WDC_WD80EFBX.*part2" "$RECOVERY_DIR/disk-id-list.txt" | awk '{print $9}'); do
device=$(ls -la /dev/disk/by-id/$disk_id | awk '{print $11}' | sed 's|../||')
echo "检查硬盘: $disk_id ($device)"
# 检查设备是否存在
if [ -b "/dev/$device" ]; then
echo " ✅ 设备存在"
# 检查设备大小
size=$(blockdev --getsize64 /dev/$device 2>/dev/null)
if [ -n "$size" ]; then
size_gb=$((size / 1024 / 1024 / 1024))
echo " ✅ 大小: ${size_gb}GB"
else
echo " ⚠️ 无法读取大小"
fi
else
echo " ❌ 设备不存在: /dev/$device"
fi
echo ""
done > "$RECOVERY_DIR/disk-status-check.txt"
cat "$RECOVERY_DIR/disk-status-check.txt"
```
**验证点**:
- [ ] 所有硬盘设备可访问
- [ ] 硬盘大小正确(约7.3TB)
### 阶段3:ZFS存储池导入(预估时间:10-30分钟)
#### 步骤3.1:查看可导入的存储池
**执行命令**:
```bash
# 查看可导入的存储池
sudo zpool import > "$RECOVERY_DIR/zpool-import-list.txt" 2>&1
cat "$RECOVERY_DIR/zpool-import-list.txt"
# 检查是否找到PoolOne
if grep -q "PoolOne" "$RECOVERY_DIR/zpool-import-list.txt"; then
echo "✅ 找到PoolOne存储池"
# 提取存储池ID
POOL_ID=$(grep -A 5 "PoolOne" "$RECOVERY_DIR/zpool-import-list.txt" | grep "id:" | awk '{print $2}')
echo "存储池ID: $POOL_ID"
echo "$POOL_ID" > "$RECOVERY_DIR/pool-id.txt"
else
echo "❌ 未找到PoolOne存储池"
echo "请检查:"
echo " 1. 所有硬盘是否已连接"
echo " 2. 硬盘是否正确识别"
exit 1
fi
```
**验证点**:
- [ ] PoolOne存储池可被识别
- [ ] 存储池ID正确(应为:162731025200795572)
- [ ] 存储池状态为ONLINE
#### 步骤3.2:导入存储池
**执行命令**:
```bash
# 1. 先尝试dry-run(如果支持)
# 注意:zpool import 不支持dry-run,直接导入
# 2. 导入存储池(使用-f强制,因为来自不同系统)
echo "开始导入存储池..."
sudo zpool import -f PoolOne > "$RECOVERY_DIR/zpool-import-output.txt" 2>&1
IMPORT_STATUS=$?
if [ $IMPORT_STATUS -eq 0 ]; then
echo "✅ 存储池导入成功"
echo "✅" > "$RECOVERY_DIR/import-status.txt"
else
echo "❌ 存储池导入失败"
cat "$RECOVERY_DIR/zpool-import-output.txt"
echo "❌" > "$RECOVERY_DIR/import-status.txt"
exit 1
fi
# 3. 检查存储池状态
sudo zpool status PoolOne > "$RECOVERY_DIR/zpool-status.txt" 2>&1
cat "$RECOVERY_DIR/zpool-status.txt"
# 4. 检查存储池信息
sudo zpool list PoolOne > "$RECOVERY_DIR/zpool-list.txt" 2>&1
cat "$RECOVERY_DIR/zpool-list.txt"
```
**验证点**:
- [ ] 存储池导入成功
- [ ] 存储池状态为ONLINE
- [ ] 所有硬盘状态为ONLINE
- [ ] 无错误信息
#### 步骤3.3:确认挂载点和数据集
**执行命令**:
```bash
# 1. 查看挂载点
df -h | grep PoolOne > "$RECOVERY_DIR/mountpoints.txt"
cat "$RECOVERY_DIR/mountpoints.txt"
# 2. 确定挂载点路径
MOUNTPOINT=$(df -h | grep PoolOne | awk '{print $6}' | head -1)
echo "挂载点: $MOUNTPOINT"
echo "$MOUNTPOINT" > "$RECOVERY_DIR/mountpoint-path.txt"
# 3. 查看数据集列表
sudo zfs list -o name,type,used,avail,refer,mountpoint -r PoolOne > "$RECOVERY_DIR/zfs-list.txt" 2>&1
cat "$RECOVERY_DIR/zfs-list.txt"
# 4. 检查uploads目录
UPLOADS_PATH="$MOUNTPOINT/apps/youyidian-3d/uploads"
if [ -d "$UPLOADS_PATH" ]; then
echo "✅ 找到uploads目录: $UPLOADS_PATH"
echo "$UPLOADS_PATH" > "$RECOVERY_DIR/uploads-path.txt"
else
echo "❌ uploads目录不存在: $UPLOADS_PATH"
# 尝试查找
UPLOADS_PATH=$(find "$MOUNTPOINT" -type d -name "uploads" -path "*/youyidian-3d/*" 2>/dev/null | head -1)
if [ -n "$UPLOADS_PATH" ]; then
echo "✅ 找到uploads目录: $UPLOADS_PATH"
echo "$UPLOADS_PATH" > "$RECOVERY_DIR/uploads-path.txt"
else
echo "❌ 无法找到uploads目录"
fi
fi
```
**验证点**:
- [ ] 存储池已挂载
- [ ] 挂载点路径正确(应为:/mnt/PoolOne或/PoolOne)
- [ ] uploads目录存在
### 阶段4:数据状态检查和分析(预估时间:1-2小时)
#### 步骤4.1:详细检查uploads目录
**执行命令**:
```bash
UPLOADS_PATH=$(cat "$RECOVERY_DIR/uploads-path.txt" 2>/dev/null)
if [ -n "$UPLOADS_PATH" ] && [ -d "$UPLOADS_PATH" ]; then
echo "=== uploads目录详细信息 ==="
# 1. 目录内容
ls -lah "$UPLOADS_PATH" > "$RECOVERY_DIR/uploads-ls.txt" 2>&1
echo "目录内容:"
cat "$RECOVERY_DIR/uploads-ls.txt"
# 2. 目录大小
du -sh "$UPLOADS_PATH" > "$RECOVERY_DIR/uploads-size.txt" 2>&1
echo ""
echo "目录大小:"
cat "$RECOVERY_DIR/uploads-size.txt"
# 3. 文件数量统计
FILE_COUNT=$(find "$UPLOADS_PATH" -type f 2>/dev/null | wc -l)
DIR_COUNT=$(find "$UPLOADS_PATH" -type d 2>/dev/null | wc -l)
echo ""
echo "文件数量: $FILE_COUNT"
echo "目录数量: $DIR_COUNT"
echo "$FILE_COUNT" > "$RECOVERY_DIR/uploads-file-count.txt"
# 4. 目录inode信息
stat "$UPLOADS_PATH" > "$RECOVERY_DIR/uploads-stat.txt" 2>&1
echo ""
echo "目录inode信息:"
cat "$RECOVERY_DIR/uploads-stat.txt"
# 5. 查找隐藏文件
find "$UPLOADS_PATH" -type f -name ".*" > "$RECOVERY_DIR/uploads-hidden.txt" 2>/dev/null
if [ -s "$RECOVERY_DIR/uploads-hidden.txt" ]; then
echo ""
echo "隐藏文件:"
cat "$RECOVERY_DIR/uploads-hidden.txt"
else
echo ""
echo "未找到隐藏文件"
fi
else
echo "❌ uploads目录不存在或无法访问"
fi
```
**验证点**:
- [ ] uploads目录可访问
- [ ] 目录状态已记录
- [ ] 文件数量已统计(预期为0或很少)
#### 步骤4.2:ZFS操作历史分析
**执行命令**:
```bash
echo "=== 导出ZFS操作历史 ==="
# 1. 导出完整历史记录
sudo zfs history PoolOne > "$RECOVERY_DIR/zfs-history-full.txt" 2>&1
# 2. 查看最近100条记录
tail -100 "$RECOVERY_DIR/zfs-history-full.txt" > "$RECOVERY_DIR/zfs-history-recent.txt"
echo "最近100条操作记录:"
cat "$RECOVERY_DIR/zfs-history-recent.txt"
# 3. 搜索与uploads相关的操作
grep -i "upload\|delete\|rm\|clean" "$RECOVERY_DIR/zfs-history-full.txt" > "$RECOVERY_DIR/zfs-history-uploads.txt" 2>&1
if [ -s "$RECOVERY_DIR/zfs-history-uploads.txt" ]; then
echo ""
echo "与uploads/删除相关的操作:"
cat "$RECOVERY_DIR/zfs-history-uploads.txt"
else
echo ""
echo "未找到与uploads/删除相关的操作"
fi
# 4. 搜索1月13日的操作
grep "2026-01-13\|Jan 13" "$RECOVERY_DIR/zfs-history-full.txt" > "$RECOVERY_DIR/zfs-history-jan13.txt" 2>&1
if [ -s "$RECOVERY_DIR/zfs-history-jan13.txt" ]; then
echo ""
echo "1月13日的操作:"
head -50 "$RECOVERY_DIR/zfs-history-jan13.txt"
fi
```
**验证点**:
- [ ] ZFS历史记录已导出
- [ ] 找到删除操作的时间点
- [ ] 记录了相关的事务组ID
#### 步骤4.3:ZFS元数据导出
**执行命令**:
```bash
echo "=== 导出ZFS元数据 ==="
echo "注意:这可能需要较长时间(30分钟到几小时)"
# 1. 导出ZFS标签信息(快速)
echo "1. 导出ZFS标签信息..."
sudo zdb -l PoolOne > "$RECOVERY_DIR/zfs-label.txt" 2>&1
echo "✅ ZFS标签信息已导出"
# 2. 导出事务组信息(中等时间)
echo "2. 导出事务组信息..."
sudo zdb -u PoolOne > "$RECOVERY_DIR/zfs-uberblock.txt" 2>&1
echo "✅ 事务组信息已导出"
# 3. 导出数据集信息
echo "3. 导出数据集信息..."
sudo zdb -d PoolOne > "$RECOVERY_DIR/zfs-dataset.txt" 2>&1
echo "✅ 数据集信息已导出"
# 4. 导出对象信息(最耗时,可选)
echo "4. 导出对象信息(这可能需要数小时)..."
echo "是否继续?(y/N)"
# 注意:在实际执行时,用户需要确认
# sudo zdb -o PoolOne > "$RECOVERY_DIR/zfs-objects.txt" 2>&1 &
echo ""
echo "元数据导出完成,文件保存在: $RECOVERY_DIR"
ls -lh "$RECOVERY_DIR"/zfs-*.txt
```
**验证点**:
- [ ] ZFS标签信息已导出
- [ ] 事务组信息已导出
- [ ] 数据集信息已导出
- [ ] 文件大小合理(不为0)
#### 步骤4.4:ZFS元数据分析
**执行命令**:
```bash
echo "=== 分析ZFS元数据 ==="
# 1. 分析事务组信息
if [ -f "$RECOVERY_DIR/zfs-uberblock.txt" ]; then
echo "分析事务组信息..."
# 查找1月13日附近的事务组
grep -E "Jan 13|2026-01-13|275321" "$RECOVERY_DIR/zfs-uberblock.txt" > "$RECOVERY_DIR/zfs-uberblock-jan13.txt" 2>&1
if [ -s "$RECOVERY_DIR/zfs-uberblock-jan13.txt" ]; then
echo "找到1月13日相关的事务组:"
head -50 "$RECOVERY_DIR/zfs-uberblock-jan13.txt"
fi
fi
# 2. 分析数据集信息
if [ -f "$RECOVERY_DIR/zfs-dataset.txt" ]; then
echo ""
echo "分析数据集信息..."
# 查找uploads相关的数据集
grep -i "upload" "$RECOVERY_DIR/zfs-dataset.txt" > "$RECOVERY_DIR/zfs-dataset-uploads.txt" 2>&1
if [ -s "$RECOVERY_DIR/zfs-dataset-uploads.txt" ]; then
echo "找到uploads相关的数据集信息:"
head -50 "$RECOVERY_DIR/zfs-dataset-uploads.txt"
fi
fi
# 3. 分析对象信息(如果已导出)
if [ -f "$RECOVERY_DIR/zfs-objects.txt" ]; then
echo ""
echo "分析对象信息..."
# 搜索uploads相关的对象
grep -i "upload" "$RECOVERY_DIR/zfs-objects.txt" | head -100 > "$RECOVERY_DIR/zfs-objects-uploads.txt" 2>&1
if [ -s "$RECOVERY_DIR/zfs-objects-uploads.txt" ]; then
echo "找到uploads相关的对象:"
head -50 "$RECOVERY_DIR/zfs-objects-uploads.txt"
fi
fi
```
**验证点**:
- [ ] 事务组信息已分析
- [ ] 找到1月13日相关的事务组
- [ ] 数据集信息已分析
- [ ] 对象信息已分析(如果可用)
### 阶段5:恢复尝试(预估时间:数小时到数天)
#### 方法5.1:ZFS调试工具深度分析
**执行命令**:
```bash
echo "=== ZFS调试工具深度分析 ==="
# 1. 查看块指针信息
echo "1. 导出块指针信息..."
sudo zdb -bb PoolOne > "$RECOVERY_DIR/zfs-blocks.txt" 2>&1 &
BLOCKS_PID=$!
echo "块指针信息导出中(PID: $BLOCKS_PID),这可能需要很长时间..."
# 2. 查看数据集树结构
echo "2. 导出数据集树结构..."
sudo zdb -P -t PoolOne > "$RECOVERY_DIR/zfs-tree.txt" 2>&1
echo "✅ 数据集树结构已导出"
# 3. 查看详细的对象信息(针对特定数据集)
echo "3. 尝试查看youyidian-3d数据集的详细信息..."
# 注意:这需要数据集路径,但PoolOne/apps/youyidian-3d可能不存在作为独立数据集
sudo zdb -d PoolOne | grep -A 20 "youyidian" > "$RECOVERY_DIR/zfs-youyidian-detail.txt" 2>&1 || true
```
#### 方法5.2:专业ZFS恢复工具(如果可用)
**选项**:
1. R-Studio(商业软件,支持ZFS)
2. UFS Explorer(商业软件,支持ZFS)
3. 专业数据恢复服务
#### 方法5.3:底层块设备扫描(最后手段,风险高)
**警告**:此方法需要导出存储池,风险较高。
**执行命令**:
```bash
echo "⚠️ 警告:此操作需要导出存储池,风险较高"
echo "是否继续?(输入 'YES' 确认)"
# 注意:在实际执行时,用户需要明确确认
# 1. 导出存储池
# sudo zfs unmount PoolOne
# sudo zpool export PoolOne
# 2. 对每块硬盘进行扫描(需要用户手动操作photorec)
# sudo photorec /dev/sdX # X是硬盘设备
```
## 关键检查点总结
### 每个阶段结束后的检查清单
**阶段1结束**:
- [ ] Ubuntu环境准备完成
- [ ] 所有工具安装成功
- [ ] 恢复目录创建成功
**阶段2结束**:
- [ ] 所有7块硬盘已连接
- [ ] 硬盘识别正确
- [ ] 设备映射关系已记录
**阶段3结束**:
- [ ] 存储池导入成功
- [ ] 存储池状态正常
- [ ] uploads目录可访问
**阶段4结束**:
- [ ] ZFS历史记录已导出
- [ ] ZFS元数据已导出
- [ ] 初步分析完成
**阶段5结束**:
- [ ] 所有恢复方法已尝试
- [ ] 恢复结果已记录
- [ ] 下一步计划已确定
## 风险评估
### 低风险操作
- ✅ 查看ZFS历史记录
- ✅ 导出ZFS元数据
- ✅ 分析ZFS结构
### 中风险操作
- ⚠️ 导入ZFS存储池(可能影响TrueNAS)
- ⚠️ 导出ZFS元数据(长时间操作)
### 高风险操作
- ❌ 导出存储池(可能丢失数据)
- ❌ 底层块设备扫描(可能损坏数据)
## 时间估算
| 阶段 | 预估时间 | 说明 |
|------|---------|------|
| 阶段1:环境准备 | 30分钟 | 软件安装 |
| 阶段2:硬盘连接 | 1-2小时 | 硬件连接和识别 |
| 阶段3:存储池导入 | 10-30分钟 | ZFS导入 |
| 阶段4:数据分析 | 1-2小时 | 数据检查和元数据导出 |
| 阶段5:恢复尝试 | 数小时到数天 | 取决于方法和数据量 |
| **总计** | **3-6小时(基础)+ 数小时到数天(恢复)** | |
## 成功概率评估
| 方法 | 成功概率 | 说明 |
|------|---------|------|
| ZFS历史记录分析 | 10% | 可能找到删除操作的线索,但无法直接恢复数据 |
| ZFS元数据分析 | 20-30% | 需要深入理解ZFS结构,可能找到已删除对象的元数据 |
| 专业ZFS恢复工具 | 60-80% | 专业工具和技术,但需要购买或专业服务 |
| 底层块设备扫描 | 5-10% | raidz2配置和ZFS限制使恢复非常困难 |
## 下一步行动
1. **确认Ubuntu设备准备情况**
2. **确认所有7块硬盘可以连接**
3. **开始执行阶段1和阶段2**
4. **根据检查结果决定后续步骤**
## 注意事项
1. **只读操作优先**:尽可能使用只读模式
2. **记录所有操作**:记录每一步操作和输出
3. **备份当前状态**:如果可能,先备份当前状态
4. **时间窗口**:删除后时间越短,恢复可能性越大
5. **专业帮助**:如果遇到困难,考虑专业数据恢复服务

View File

@ -29,15 +29,15 @@ const UPDATE_LOGS: UpdateLog[] = [
items: [
{
type: 'feature',
title: '产品全案素材类型',
title: '任务-详情素材类型',
description:
'新增"产品全案"素材类型,用于整合一个产品的完整电商视觉资产。支持上传主图、SKU图、详情预览图和PSD源文件,可按类型分类下载(全部/主图/SKU图/详情图/PSD)。上传时需填写任务号和SPU型号,详情页支持拖拽排序调整详情图顺序。产品全案图片保持原图质量,不做压缩处理,确保素材完整性。'
'新增"任务-详情"素材类型,用于整合一个产品的完整电商视觉资产。支持上传主图、SKU图、详情预览图和PSD源文件,可按类型分类下载(全部/主图/SKU图/详情图/PSD)。上传时需填写任务号和SPU型号,详情页支持拖拽排序调整详情图顺序。任务-详情图片保持原图质量,不做压缩处理,确保素材完整性。'
},
{
type: 'feature',
title: '外部接口对接功能',
description:
'产品全案支持外部系统对接,任务系统可通过任务号查询、下载和删除产品全案素材。管理员可在后台管理API密钥,查看接口调用日志,方便监控和管理外部访问。'
'任务-详情支持外部系统对接,任务系统可通过任务号查询、下载和删除任务-详情素材。管理员可在后台管理API密钥,查看接口调用日志,方便监控和管理外部访问。'
},
{
type: 'fix',

View File

@ -52,6 +52,9 @@ import {
BatchXiaohongshuUI,
} from '@/components/upload/SpecialBatchUploadUI'
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
import ThreeDOriginalUploader from '@/components/upload/ThreeDOriginalUploader'
import AIOriginalUploader from '@/components/upload/AIOriginalUploader'
import RealPhotoUploader from '@/components/upload/RealPhotoUploader'
interface UploadModalProps {
onClose: () => void
@ -113,6 +116,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// 任务号(所有素材类型支持)
const [taskNo, setTaskNo] = useState('')
// SKU(视频类素材使用)
const [sku, setSku] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
@ -159,9 +165,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}, [materialType])
// 验证任务号格式(与后端保持一致)
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:TASK202601030184 或 TASK202601071437194808-01 或 TASK202601141713525624-01-01
const validateTaskNo = (value: string): boolean => {
if (!value.trim()) return true // 空值允许(可选字段,但提交时会验证)
const regex = /^TASK\d{12,18}(-\d{2})?$/
const regex = /^TASK\d{12,18}(-\d{2})*$/
return regex.test(value)
}
@ -1276,7 +1284,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
// 任务号验证(可选,仅验证格式,不强制要求)
if (taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号')
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)')
return
}
@ -1458,31 +1466,38 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onChange={(e) => setMaterialType(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
>
{/* 3D相关 */}
<option value="3D场景">3D场景</option>
<option value="材质">材质</option>
<option value="标准模型">标准模型</option>
<option value="标准饰品">标准饰品</option>
{/* 图片相关 */}
<option value="详情页">详情页</option>
<option value="详情模板">详情模板</option>
<option value="材质">材质</option>
<option value="模特">模特</option>
<option value="实拍图片">实拍图片</option>
<option value="买家秀图片">买家秀图片</option>
<option value="小红书种草">小红书种草</option>
<option value="主图模版">主图模版</option>
<option value="SKU模版">SKU模版</option>
{/* 视频相关 */}
<option value="主图视频">主图视频</option>
<option value="安装视频">安装视频</option>
<option value="种草视频">种草视频</option>
{/* 模板和种草 */}
<option value="主图模版">主图模版</option>
<option value="SKU模版">SKU模版</option>
<option value="小红书种草">小红书种草</option>
{/* 其他 */}
<option value="音乐">音乐</option>
<option value="C4D模型">C4D模型</option>
<option value="标准饰品">标准饰品</option>
<option value="标准模型">标准模型</option>
<option value="品牌VI灵感">品牌VI灵感</option>
<option value="矢量文件">矢量文件</option>
<option value="产品全案">产品全案</option>
{/* 任务相关 */}
<option value="任务-详情">任务-详情</option>
<option value="任务-3D原图">任务-3D原图</option>
<option value="任务-AI原图">任务-AI原图</option>
<option value="任务-实拍原图">任务-实拍原图</option>
</select>
</div>
{/* 任务号输入框(产品全案不显示,因为产品全案有自己的任务号输入) */}
{materialType !== '产品全案' && (
{/* 任务号输入框(任务-详情/任务-3D原图/任务-AI原图/任务-实拍原图不显示,因为有自己的任务号输入) */}
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
@ -1502,8 +1517,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
)}
{/* 产品全案上传组件 */}
{materialType === '产品全案' && (
{/* 任务-详情上传组件 */}
{materialType === '任务-详情' && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<ProductPackageUploader
onSuccess={() => {
@ -1521,8 +1536,65 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
)}
{/* 任务-3D原图上传组件 */}
{materialType === '任务-3D原图' && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<ThreeDOriginalUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 任务-AI原图上传组件 */}
{materialType === '任务-AI原图' && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<AIOriginalUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 任务-实拍原图上传组件 */}
{materialType === '任务-实拍原图' && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<RealPhotoUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 批量上传模式:可折叠文件列表 */}
{materialType !== '产品全案' && isBatchMode && batchFiles.length > 0 && (
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && isBatchMode && batchFiles.length > 0 && (
<BatchFileList
batchFiles={batchFiles}
materialType={materialType}
@ -1605,8 +1677,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
/>
)}
{/* 预览图片(产品全案不显示,批量模式下隐藏) */}
{materialType !== '产品全案' && !isBatchMode && (
{/* 预览图片(任务类型不显示,批量模式下隐藏) */}
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && !isBatchMode && (
<PreviewImageUpload
materialType={materialType}
previewImages={previewImages}
@ -1656,8 +1728,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
/>
)}
{/* 表单字段 - 使用 UploadForm 组件(产品全案不显示) */}
{materialType !== '产品全案' && (
{/* 表单字段 - 使用 UploadForm 组件(任务类型不显示) */}
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
<UploadForm
materialType={materialType}
productModel={productModel}
@ -1666,6 +1738,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
platformName={platformName}
productCategoryName={productCategoryName}
renderer={renderer}
sku={sku}
categories={categories}
scenes={scenes}
platforms={platforms}
@ -1677,6 +1750,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={setRenderer}
onSkuChange={setSku}
onAddCategory={async (name) => {
await addCategory(
name,
@ -1702,8 +1776,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
/>
)}
{/* Material File(产品全案不显示) */}
{materialType !== '产品全案' && (
{/* Material File(任务类型不显示) */}
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材文件 {(materialType === '模特' || materialType === '标准模型') ? '(可选)' : '*'}
@ -1981,8 +2055,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
)}
{/* Buttons(产品全案不显示,因为有自己的按钮) */}
{materialType !== '产品全案' && (
{/* Buttons(任务类型不显示,因为有自己的按钮) */}
{materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"

View File

@ -1,5 +1,5 @@
/**
* 产品全案详情组件
* 任务-详情详情组件
*/
'use client';

View File

@ -0,0 +1,285 @@
/**
* 任务-3D原图详情组件
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { parseDownloadError } from '@/lib/download-errors';
interface Asset {
id: string;
assetType: string;
fileUrl: string;
fileName: string;
fileSize: number;
sortOrder: number;
}
interface ThreeDOriginalDetailProps {
material: {
id: string;
taskNo?: string;
productModel: string;
categoryName: string;
sceneName: string;
taskStatus?: string;
taskFetchedAt?: string;
assets: Asset[];
};
canDownloadSource: boolean;
onDownload: (type: string, assetId?: string) => Promise<void>;
onBack?: () => void;
showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void;
}
export default function ThreeDOriginalDetail({
material,
canDownloadSource,
onDownload,
onBack,
showToast,
}: ThreeDOriginalDetailProps) {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [downloading, setDownloading] = useState(false);
const [downloadType, setDownloadType] = useState<string>('');
// 轮播相关状态
const [autoPlayEnabled, setAutoPlayEnabled] = useState(true);
// 按类型分组资产
// ⚠️ 注意:preview是S3存储的压缩预览图(小文件),用于网页展示
// zip是本地存储的原图压缩包(大文件),用于下载
const mainImages = material.assets.filter((a) => a.assetType === 'preview').sort((a, b) => a.sortOrder - b.sortOrder);
const zipFile = material.assets.find((a) => a.assetType === 'zip');
// 轮播逻辑
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 清理之前的定时器
if (autoPlayTimerRef.current) {
clearInterval(autoPlayTimerRef.current);
}
// 只在有多张原图时启用轮播
if (autoPlayEnabled && mainImages.length > 1) {
autoPlayTimerRef.current = setInterval(() => {
setCurrentImageIndex((prev) => (prev + 1) % mainImages.length);
}, 5000); // 5秒轮播
}
return () => {
if (autoPlayTimerRef.current) {
clearInterval(autoPlayTimerRef.current);
}
};
}, [currentImageIndex, autoPlayEnabled, mainImages.length]);
// 格式化文件大小
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 警告:已被外部系统获取 */}
{material.taskStatus === 'fetched' && material.taskFetchedAt && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<svg className="w-5 h-5 text-yellow-600 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<h4 className="text-sm font-medium text-yellow-800">注意:此素材已被外部系统获取</h4>
<p className="text-sm text-yellow-700 mt-1">
获取时间:{new Date(material.taskFetchedAt).toLocaleString('zh-CN')}
</p>
<p className="text-sm text-yellow-700">编辑或删除此素材可能影响外部系统的使用</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-[2fr_1fr] gap-6">
{/* 左侧:原图展示 */}
<div className="space-y-6">
{/* 原图预览区 */}
{mainImages.length > 0 && (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium">
原图 ({currentImageIndex + 1}/{mainImages.length})
</h4>
{mainImages.length > 1 && (
<button
onClick={() => setAutoPlayEnabled(!autoPlayEnabled)}
className="text-xs text-gray-500 hover:text-gray-700"
type="button"
>
{autoPlayEnabled ? '暂停轮播' : '开始轮播'}
</button>
)}
</div>
<div className="relative">
<img
src={mainImages[currentImageIndex]?.fileUrl}
alt={`原图 ${currentImageIndex + 1}`}
className="w-full rounded-lg"
/>
</div>
</div>
)}
{/* 原图小卡片展示区 */}
{mainImages.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h4 className="text-sm font-medium mb-3 text-gray-900 dark:text-white">所有原图 ({mainImages.length})</h4>
<div className="flex flex-wrap gap-2">
{mainImages.map((img, index) => (
<button
key={img.id}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// 阻止滚动行为
const currentScrollY = window.scrollY;
setCurrentImageIndex(index);
setAutoPlayEnabled(false); // 手动切换时暂停轮播
// 保持滚动位置
requestAnimationFrame(() => {
window.scrollTo(0, currentScrollY);
});
}}
onMouseDown={(e) => {
// 阻止默认的鼠标按下行为
e.preventDefault();
}}
className={`relative flex-shrink-0 border-2 rounded overflow-hidden ${
index === currentImageIndex
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300'
}`}
style={{ width: '84px', height: '84px' }}
type="button"
>
<img
src={img.fileUrl}
alt={`原图 ${index + 1}`}
className="w-full h-full object-cover"
/>
{index === currentImageIndex && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-20 flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
))}
</div>
</div>
)}
</div>
{/* 右侧:基本信息 + 下载按钮 */}
<div className="space-y-6">
{/* 基本信息卡片 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-medium mb-4 text-gray-900 dark:text-white">素材详情</h3>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.productModel}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">店铺</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.categoryName}</p>
</div>
{material.sceneName && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">场景</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.sceneName}</p>
</div>
)}
</div>
</div>
{/* ZIP 文件信息和下载按钮 - 使用容器实现sticky,保持相对位置 */}
<div className="sticky top-24 z-10 space-y-6">
{/* ZIP 源文件 */}
{zipFile && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h4 className="text-sm font-medium mb-3 text-gray-900 dark:text-white">源文件</h4>
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded">
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">{zipFile.fileName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{formatBytes(zipFile.fileSize)}</p>
</div>
{canDownloadSource ? (
<button
onClick={async () => {
setDownloading(true);
setDownloadType('zip');
try {
await onDownload('zip', zipFile.id);
setTimeout(() => {
setDownloading(false);
setDownloadType('');
}, 500);
} catch (error) {
console.error('下载失败:', error);
const { message } = parseDownloadError(error);
if (showToast) {
showToast(message, { variant: 'error' });
}
setDownloading(false);
setDownloadType('');
}
}}
disabled={downloading}
className="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
>
{downloading && downloadType === 'zip' ? (
<div className="flex items-center space-x-2">
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>下载中...</span>
</div>
) : (
'下载'
)}
</button>
) : (
<span className="text-xs text-gray-400">无权限</span>
)}
</div>
</div>
)}
{/* 返回上一页按钮 */}
{onBack && (
<button
onClick={onBack}
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-base font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
返回上一页
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,759 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface ThreeDOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function ThreeDOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件(任务-3D原图:预览图、原图)
const [previewImages, setPreviewImages] = useState<AssetFile[]>([]);
const [mainImages, setMainImages] = useState<AssetFile[]>([]); // 原图列表,后端会自动压缩为zip
// 拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const previewInputRef = useRef<HTMLInputElement>(null);
const mainInputRef = useRef<HTMLInputElement>(null); // 原图输入
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true; // 任务号可选
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
await onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
await onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理图片文件选择
const handleImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = previewImages;
Array.from(files).forEach((file, index) => {
// 只接受图片文件
if (!file.type.startsWith('image/')) {
setError(`${file.name} 不是有效的图片文件`);
return;
}
// 文件大小检查(50MB限制)
if (file.size > 50 * 1024 * 1024) {
setError(`${file.name} 超过50MB,请选择较小的文件`);
return;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + index,
});
});
setPreviewImages([...currentList, ...newFiles]);
setError('');
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
// 只接受图片文件
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
// 单个文件大小检查(100MB限制)
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
// 创建预览
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除预览图
const removePreviewImage = (index: number) => {
const newList = previewImages.filter((_, i) => i !== index);
URL.revokeObjectURL(previewImages[index].preview);
setPreviewImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除ZIP文件函数已移除(不再需要)
// 拖拽处理
const handleDragOver = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(true);
else setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
const files = e.dataTransfer.files;
if (type === 'main') {
handleMainImageSelect(files);
} else {
handleImageSelect(files);
}
};
// 上传到S3
const uploadToS3 = async (file: File, type: string): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const response = await fetch('/api/upload/s3', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `上传 ${type} 文件失败`);
}
const data = await response.json();
return data.url;
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (previewImages.length === 0) {
setError('请至少上传1张预览图');
return;
}
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/3d-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传文件(10% -> 80%)
const totalFiles = previewImages.length + mainImages.length;
let uploadedFiles = 0;
const updateProgress = () => {
uploadedFiles++;
const progress = 10 + (uploadedFiles / totalFiles) * 70;
setUploadProgress(Math.round(progress));
};
// 上传图片资产(预览图和原图都使用S3)
const uploadAssets = async (
assets: AssetFile[],
assetType: string
): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string }>> => {
if (assets.length === 0) return [];
const uploadPromises = assets.map(async (asset, index) => {
try {
const imageUrl = await uploadToS3(asset.file, assetType);
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
fileUrl: imageUrl,
sortOrder: index,
};
} catch (error) {
console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error);
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 并行上传所有资产(预览图和原图)
const [previewAssets, mainAssets] = await Promise.all([
uploadAssets(previewImages, 'preview'),
uploadAssets(mainImages, 'main'), // 原图使用main类型,后端会自动压缩为zip
]);
setUploadProgress(80);
// 步骤3: 完成上传,后端会自动生成zip压缩包(80% -> 100%)
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: previewAssets,
main: mainAssets, // 传递原图,后端会自动生成zip
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
previewImages.forEach(img => URL.revokeObjectURL(img.preview));
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
// 成功回调
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: any) {
console.error('[3D Original Upload Error]', err);
setError(err.message || '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false);
setNewCategoryName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建店铺
</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewScene}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false);
setNewSceneName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建场景类别
</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 预览图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图 * <span className="text-gray-500 text-xs">(至少1张,支持拖拽排序)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'preview')}
onDragLeave={(e) => handleDragLeave(e, 'preview')}
onDrop={(e) => handleDrop(e, 'preview')}
>
<input
ref={previewInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择预览图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域
</p>
</div>
{/* 预览图列表 */}
{previewImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`预览图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removePreviewImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台会自动压缩为zip)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'main')}
onDragLeave={(e) => handleDragLeave(e, 'main')}
onDrop={(e) => handleDrop(e, 'main')}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
</button>
<button
onClick={handleSubmit}
className="btn-primary disabled:opacity-50"
disabled={uploading}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,533 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface AIOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function AIOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: AIOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 原图列表(后端会自动压缩为zip并生成预览图)
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const mainInputRef = useRef<HTMLInputElement>(null);
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true;
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
handleMainImageSelect(e.dataTransfer.files);
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/ai-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传原图(10% -> 80%)
const { chunkUpload } = await import('@/lib/chunk-upload');
const mainAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
for (let i = 0; i < mainImages.length; i++) {
const asset = mainImages[i];
try {
const result = await chunkUpload(asset.file, {
materialType: 'AI原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (i + progress / 100) / mainImages.length;
const totalProgress = 10 + fileProgress * 70;
setUploadProgress(Math.round(totalProgress));
},
});
mainAssets.push({
uploadId: result.uploadId,
fileName: asset.file.name,
fileSize: asset.file.size,
});
} catch (error) {
console.error(`[Upload] Failed to upload original ${i}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
}
setUploadProgress(80);
// 步骤3: 完成上传(80% -> 100%)
// 后端会:1. 打包原图为zip 2. 为每张原图生成预览图
const completeResponse = await fetch('/api/materials/upload/ai-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: [], // 不传预览图,后端自动生成
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index,
})),
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[AI Original Upload Error]', err);
setError(err instanceof Error ? err.message : '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewCategory} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewScene} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</div>
)}
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
/**
* 产品全案上传组件
* 任务-详情上传组件
*/
'use client';
@ -42,6 +42,8 @@ export default function ProductPackageUploader({
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
@ -67,10 +69,13 @@ export default function ProductPackageUploader({
const psdInputRef = useRef<HTMLInputElement>(null);
// 验证任务号格式
// 支持格式:TASK + 12-18位数字 + 可选的子任务号(-2位数字)
// 示例:TASK202601030184 或 TASK202601071437194808 或 TASK202601071437194808-01
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:
// TASK202601030184 - 基础任务号
// TASK202601071437194808-01 - 一级子任务
// TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务)
const validateTaskNo = (value: string): boolean => {
const regex = /^TASK\d{12,18}(-\d{2})?$/;
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(value);
};
@ -261,7 +266,7 @@ export default function ProductPackageUploader({
// 如果提供了任务号,验证格式
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号');
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return;
}
@ -285,6 +290,8 @@ export default function ProductPackageUploader({
productModel,
categoryName,
sceneName,
platformName: platformName || undefined,
productCategoryName: productCategoryName || undefined,
}),
});
@ -400,7 +407,10 @@ export default function ProductPackageUploader({
});
if (!completeResponse.ok) {
throw new Error('完成上传失败');
const errorData = await completeResponse.json().catch(() => ({}));
const errorMessage = errorData.error || '完成上传失败';
console.error('[Upload] Complete failed:', errorMessage, errorData);
throw new Error(errorMessage);
}
onSuccess();
@ -416,7 +426,7 @@ export default function ProductPackageUploader({
return (
<div className="space-y-6">
{/* 任务号字段(产品全案独有) */}
{/* 任务号字段(任务-详情独有) */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
@ -437,12 +447,12 @@ export default function ProductPackageUploader({
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
<UploadForm
materialType="产品全案"
materialType="任务-详情"
productModel={productModel}
categoryName={categoryName}
sceneName={sceneName}
platformName=""
productCategoryName=""
platformName={platformName}
productCategoryName={productCategoryName}
renderer=""
categories={categories}
scenes={scenes}
@ -452,8 +462,8 @@ export default function ProductPackageUploader({
onProductModelChange={setProductModel}
onCategoryChange={setCategoryName}
onSceneChange={setSceneName}
onPlatformChange={() => {}}
onProductCategoryChange={() => {}}
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={() => {}}
onAddCategory={handleAddNewCategory}
onAddScene={handleAddNewScene}

View File

@ -0,0 +1,530 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface RealPhotoUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function RealPhotoUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: RealPhotoUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 原图列表(后端会自动压缩为zip并生成预览图)
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const mainInputRef = useRef<HTMLInputElement>(null);
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true;
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
handleMainImageSelect(e.dataTransfer.files);
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/real-photo/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传原图(10% -> 80%)
const { chunkUpload } = await import('@/lib/chunk-upload');
const mainAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
for (let i = 0; i < mainImages.length; i++) {
const asset = mainImages[i];
try {
const result = await chunkUpload(asset.file, {
materialType: '实拍原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (i + progress / 100) / mainImages.length;
const totalProgress = 10 + fileProgress * 70;
setUploadProgress(Math.round(totalProgress));
},
});
mainAssets.push({
uploadId: result.uploadId,
fileName: asset.file.name,
fileSize: asset.file.size,
});
} catch (error) {
console.error(`[Upload] Failed to upload original ${i}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
}
setUploadProgress(80);
// 步骤3: 完成上传(80% -> 100%)
const completeResponse = await fetch('/api/materials/upload/real-photo/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: [],
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index,
})),
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[Real Photo Upload Error]', err);
setError(err instanceof Error ? err.message : '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewCategory} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewScene} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</div>
)}
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,533 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface ThreeDOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function ThreeDOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 原图列表(后端会自动压缩为zip并生成预览图)
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const mainInputRef = useRef<HTMLInputElement>(null);
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true;
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsMainDragging(false);
handleMainImageSelect(e.dataTransfer.files);
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/3d-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传原图(10% -> 80%)
const { chunkUpload } = await import('@/lib/chunk-upload');
const mainAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
for (let i = 0; i < mainImages.length; i++) {
const asset = mainImages[i];
try {
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (i + progress / 100) / mainImages.length;
const totalProgress = 10 + fileProgress * 70;
setUploadProgress(Math.round(totalProgress));
},
});
mainAssets.push({
uploadId: result.uploadId,
fileName: asset.file.name,
fileSize: asset.file.size,
});
} catch (error) {
console.error(`[Upload] Failed to upload original ${i}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
}
setUploadProgress(80);
// 步骤3: 完成上传(80% -> 100%)
// 后端会:1. 打包原图为zip 2. 为每张原图生成预览图
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: [], // 不传预览图,后端自动生成
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index,
})),
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[3D Original Upload Error]', err);
setError(err instanceof Error ? err.message : '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewCategory} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建店铺</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewScene} className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple" disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple" disabled={uploading}>取消</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-apple whitespace-nowrap" disabled={uploading}>+ 新建场景类别</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</div>
)}
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button onClick={onCancel} className="btn-secondary" disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className="btn-primary disabled:opacity-50" disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,849 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface ThreeDOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function ThreeDOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件(任务-3D原图:预览图、原图)
const [previewImages, setPreviewImages] = useState<AssetFile[]>([]);
const [mainImages, setMainImages] = useState<AssetFile[]>([]); // 原图列表,后端会自动压缩为zip
// 拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const [isMainDragging, setIsMainDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const previewInputRef = useRef<HTMLInputElement>(null);
const mainInputRef = useRef<HTMLInputElement>(null); // 原图输入
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true; // 任务号可选
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
await onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
await onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理图片文件选择
const handleImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = previewImages;
Array.from(files).forEach((file, index) => {
// 只接受图片文件
if (!file.type.startsWith('image/')) {
setError(`${file.name} 不是有效的图片文件`);
return;
}
// 文件大小检查(50MB限制)
if (file.size > 50 * 1024 * 1024) {
setError(`${file.name} 超过50MB,请选择较小的文件`);
return;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + index,
});
});
setPreviewImages([...currentList, ...newFiles]);
setError('');
};
// 处理原图文件选择(可多选)
const handleMainImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = mainImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
// 只接受图片文件
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`);
continue;
}
// 单个文件大小检查(100MB限制)
if (file.size > 100 * 1024 * 1024) {
setError(`图片 ${file.name} 超过100MB,请选择较小的文件`);
continue;
}
// 创建预览
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + newFiles.length,
});
}
if (newFiles.length > 0) {
setMainImages([...currentList, ...newFiles]);
setError('');
}
};
// 删除预览图
const removePreviewImage = (index: number) => {
const newList = previewImages.filter((_, i) => i !== index);
URL.revokeObjectURL(previewImages[index].preview);
setPreviewImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除原图
const removeMainImage = (index: number) => {
const newList = mainImages.filter((_, i) => i !== index);
URL.revokeObjectURL(mainImages[index].preview);
setMainImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除ZIP文件函数已移除(不再需要)
// 拖拽处理
const handleDragOver = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(true);
else setIsMainDragging(true);
};
const handleDragLeave = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
};
const handleDrop = (e: React.DragEvent, type: 'preview' | 'main') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsMainDragging(false);
const files = e.dataTransfer.files;
if (type === 'main') {
handleMainImageSelect(files);
} else {
handleImageSelect(files);
}
};
// 上传预览图到S3(压缩)
const uploadPreviewToS3 = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'preview');
const response = await fetch('/api/upload/s3', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `上传预览图失败`);
}
const data = await response.json();
return data.url;
};
// 使用chunk upload上传原图(大文件)
const uploadOriginalWithChunk = async (file: File): Promise<string> => {
const {chunkUpload} = await import('@/lib/chunk-upload');
const metadata = {
materialType: '3D原图',
fileName: file.name,
fileSize: file.size,
};
const result = await chunkUpload(file, metadata, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true, // 不立即complete,等所有文件上传完再统一处理
onProgress: (progress) => {
// 这里可以添加进度回调
console.log(`[Original Upload] ${file.name}: ${progress}%`);
},
});
return result.uploadId; // 返回uploadId,后续用于complete
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
// 预览图改为可选,如果没有预览图会使用原图第一张
if (mainImages.length === 0) {
setError('请至少上传1张原图');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/3d-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传文件(10% -> 80%)
const totalFiles = previewImages.length + mainImages.length;
let uploadedFiles = 0;
const updateProgress = () => {
uploadedFiles++;
const progress = 10 + (uploadedFiles / totalFiles) * 70;
setUploadProgress(Math.round(progress));
};
// 上传预览图(前端压缩后上传到S3)
const uploadPreviewAssets = async (
assets: AssetFile[]
): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string }>> => {
if (assets.length === 0) return [];
// 动态导入图片处理函数
const { compressPreviewImage } = await import('./image-processor');
const uploadPromises = assets.map(async (asset, index) => {
try {
// 1. 前端压缩预览图(防止卡顿)
const compressed = await compressPreviewImage(asset.file);
// 2. 上传压缩后的预览图到S3
const imageUrl = await uploadPreviewToS3(compressed);
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
fileUrl: imageUrl,
sortOrder: index,
};
} catch (error) {
console.error(`[Upload] Failed to upload preview asset ${index}:`, error);
throw new Error(`上传预览图 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 上传原图(使用chunk上传,不压缩)
const uploadMainAssets = async (
assets: AssetFile[]
): Promise<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
if (assets.length === 0) return [];
const { chunkUpload } = await import('@/lib/chunk-upload');
const CHUNK_THRESHOLD = 5 * 1024 * 1024; // 5MB
const uploadPromises = assets.map(async (asset, index) => {
try {
// ⚠️ 关键:原图使用chunk upload,保持原始质量
// 大文件走chunk,小文件直接上传
if (asset.file.size >= CHUNK_THRESHOLD) {
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true, // 先不complete,等所有文件上传完
onProgress: (progress) => {
console.log(`[Original] ${asset.file.name}: ${progress}%`);
updateProgress();
},
});
return {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: result.uploadId,
sortOrder: index,
};
} else {
// 小文件也走chunk,确保逻辑统一
const result = await chunkUpload(asset.file, {
materialType: '3D原图',
fileName: asset.file.name,
}, {
chunkSize: 5 * 1024 * 1024,
skipComplete: true,
});
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
uploadId: result.uploadId,
sortOrder: index,
};
}
} catch (error) {
console.error(`[Upload] Failed to upload original asset ${index}:`, error);
throw new Error(`上传原图 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 并行上传所有资产
const [previewAssets, mainAssets] = await Promise.all([
uploadPreviewAssets(previewImages), // 预览图 - 前端压缩 + S3存储
uploadMainAssets(mainImages), // 原图 - chunk上传到服务器临时目录
]);
setUploadProgress(80);
// 步骤3: 完成上传,后端会处理原图(打包zip + 生成预览图)(80% -> 100%)
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: previewAssets, // S3预览图URLs
main: mainAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index, // 使用map的index作为sortOrder
})), // 原图uploadIds,后端会从临时目录读取并打包
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
previewImages.forEach(img => URL.revokeObjectURL(img.preview));
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
// 成功回调
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: any) {
console.error('[3D Original Upload Error]', err);
setError(err.message || '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false);
setNewCategoryName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建店铺
</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewScene}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false);
setNewSceneName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建场景类别
</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 预览图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图(可选) <span className="text-gray-500 text-xs">(如不上传,将使用原图第一张作为预览图)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'preview')}
onDragLeave={(e) => handleDragLeave(e, 'preview')}
onDrop={(e) => handleDrop(e, 'preview')}
>
<input
ref={previewInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择预览图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域
</p>
</div>
{/* 预览图列表 */}
{previewImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`预览图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removePreviewImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 原图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台会自动压缩为zip)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'main')}
onDragLeave={(e) => handleDragLeave(e, 'main')}
onDrop={(e) => handleDrop(e, 'main')}
>
<input
ref={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleMainImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择原图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域(可多选)
</p>
</div>
{/* 原图列表 */}
{mainImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{mainImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`原图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeMainImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
</button>
<button
onClick={handleSubmit}
className="btn-primary disabled:opacity-50"
disabled={uploading}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}

View File

@ -34,6 +34,8 @@ export interface UploadFormProps {
productCategoryName: string
/** 渲染器 */
renderer: string
/** SKU(视频类素材和任务-详情使用) */
sku?: string
/** 店铺列表 */
categories: CategoryItem[]
/** 场景列表 */
@ -56,6 +58,8 @@ export interface UploadFormProps {
onProductCategoryChange: (value: string) => void
/** 渲染器变更回调 */
onRendererChange: (value: string) => void
/** SKU变更回调 */
onSkuChange?: (value: string) => void
/** 添加店铺回调 */
onAddCategory: (name: string) => Promise<void>
/** 添加场景回调 */
@ -73,6 +77,7 @@ export default function UploadForm({
platformName,
productCategoryName,
renderer,
sku = '',
categories,
scenes,
platforms,
@ -84,6 +89,7 @@ export default function UploadForm({
onPlatformChange,
onProductCategoryChange,
onRendererChange,
onSkuChange,
onAddCategory,
onAddScene,
}: UploadFormProps) {
@ -144,6 +150,23 @@ export default function UploadForm({
/>
</div>
{/* SKU字段(视频类素材使用) */}
{['主图视频', '安装视频', '种草视频'].includes(materialType) && (
<div>
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU <span className="text-gray-500 dark:text-gray-400 text-xs">(可选,多个SKU请用逗号分隔)</span>
</label>
<input
id="sku"
type="text"
value={sku}
onChange={(e) => onSkuChange?.(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="例如:SKU001,SKU002,SKU003"
/>
</div>
)}
{/* 品类分类(品牌VI灵感必填,标准模型必填,其他类型可选) */}
{productCategories.length > 0 && (
<div>

View File

@ -0,0 +1,758 @@
'use client'
import { useState, useRef } from 'react';
import Image from 'next/image';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface ThreeDOriginalUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function ThreeDOriginalUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ThreeDOriginalUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件(任务-3D原图只需要:预览图、压缩包)
const [previewImages, setPreviewImages] = useState<AssetFile[]>([]);
const [zipFile, setZipFile] = useState<File | null>(null);
// 拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const [isZipDragging, setIsZipDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const previewInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(null);
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true; // 任务号可选
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
await onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
await onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理图片文件选择
const handleImageSelect = (files: FileList | null) => {
if (!files) return;
const newFiles: AssetFile[] = [];
const currentList = previewImages;
Array.from(files).forEach((file, index) => {
// 只接受图片文件
if (!file.type.startsWith('image/')) {
setError(`${file.name} 不是有效的图片文件`);
return;
}
// 文件大小检查(50MB限制)
if (file.size > 50 * 1024 * 1024) {
setError(`${file.name} 超过50MB,请选择较小的文件`);
return;
}
const preview = URL.createObjectURL(file);
newFiles.push({
file,
preview,
sortOrder: currentList.length + index,
});
});
setPreviewImages([...currentList, ...newFiles]);
setError('');
};
// 处理ZIP文件选择
const handleZipSelect = (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
const ext = file.name.toLowerCase();
// 只接受压缩包文件
if (!['.zip', '.rar', '.7z'].some(e => ext.endsWith(e))) {
setError('只支持 ZIP、RAR、7Z 格式的压缩包');
return;
}
// 文件大小检查(5GB限制)
if (file.size > 5 * 1024 * 1024 * 1024) {
setError('压缩包文件超过5GB,请选择较小的文件');
return;
}
setZipFile(file);
setError('');
};
// 删除图片
const removeImage = (index: number) => {
const newList = previewImages.filter((_, i) => i !== index);
URL.revokeObjectURL(previewImages[index].preview);
setPreviewImages(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 删除ZIP文件
const removeZip = () => {
setZipFile(null);
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent, type: 'preview' | 'zip') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(true);
else setIsZipDragging(true);
};
const handleDragLeave = (e: React.DragEvent, type: 'preview' | 'zip') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsZipDragging(false);
};
const handleDrop = (e: React.DragEvent, type: 'preview' | 'zip') => {
e.preventDefault();
if (type === 'preview') setIsPreviewDragging(false);
else setIsZipDragging(false);
const files = e.dataTransfer.files;
if (type === 'zip') {
handleZipSelect(files);
} else {
handleImageSelect(files);
}
};
// 上传到S3
const uploadToS3 = async (file: File, type: string): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const response = await fetch('/api/upload/s3', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `上传 ${type} 文件失败`);
}
const data = await response.json();
return data.url;
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (previewImages.length === 0) {
setError('请至少上传1张预览图');
return;
}
if (!zipFile) {
setError('请上传压缩包文件');
return;
}
setUploading(true);
setUploadProgress(0);
try {
// 步骤1: 初始化素材记录(10%)
const initResponse = await fetch('/api/materials/upload/3d-original/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传文件(10% -> 80%)
const totalFiles = previewImages.length + 1; // +1 for zip
let uploadedFiles = 0;
const updateProgress = () => {
uploadedFiles++;
const progress = 10 + (uploadedFiles / totalFiles) * 70;
setUploadProgress(Math.round(progress));
};
// 并行上传预览图
const uploadAssets = async (
assets: AssetFile[],
assetType: string
): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string }>> => {
if (assets.length === 0) return [];
const uploadPromises = assets.map(async (asset, index) => {
try {
const imageUrl = await uploadToS3(asset.file, assetType);
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
fileUrl: imageUrl,
};
} catch (error) {
console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error);
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败`);
}
});
return await Promise.all(uploadPromises);
};
// 上传ZIP文件到本地服务器
const uploadZipFile = async (): Promise<{ fileName: string; fileSize: number; filePath: string }> => {
const formData = new FormData();
formData.append('file', zipFile!);
formData.append('materialId', materialId);
const zipResponse = await fetch('/api/upload/zip', {
method: 'POST',
body: formData,
});
if (!zipResponse.ok) {
const errorData = await zipResponse.json().catch(() => ({}));
throw new Error(errorData.error || `ZIP 文件上传失败`);
}
const zipData = await zipResponse.json();
updateProgress();
return {
fileName: zipFile!.name,
fileSize: zipFile!.size,
filePath: zipData.path,
};
};
// 并行上传所有资产
const [previewAssets, zipAsset] = await Promise.all([
uploadAssets(previewImages, 'preview'),
uploadZipFile(),
]);
setUploadProgress(80);
// 步骤3: 完成上传,保存数据库记录(80% -> 100%)
const completeResponse = await fetch('/api/materials/upload/3d-original/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
assets: {
preview: previewAssets,
zip: zipAsset,
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(100);
// 清理预览URL
previewImages.forEach(img => URL.revokeObjectURL(img.preview));
// 成功回调
setTimeout(() => {
onSuccess();
}, 500);
} catch (err: any) {
console.error('[3D Original Upload Error]', err);
setError(err.message || '上传失败,请重试');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="max-h-[80vh] overflow-y-auto">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewCategory}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false);
setNewCategoryName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建店铺
</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="输入新场景类别"
disabled={uploading}
/>
<button
type="button"
onClick={handleAddNewScene}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50"
disabled={uploading}
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false);
setNewSceneName('');
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={uploading}
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap disabled:opacity-50"
disabled={uploading}
>
+ 新建场景类别
</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 预览图上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图 * <span className="text-gray-500 text-xs">(至少1张,支持拖拽排序)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'preview')}
onDragLeave={(e) => handleDragLeave(e, 'preview')}
onDrop={(e) => handleDrop(e, 'preview')}
>
<input
ref={previewInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择预览图
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽图片到此区域
</p>
</div>
{/* 预览图列表 */}
{previewImages.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<Image
src={img.preview}
alt={`预览图 ${index + 1}`}
width={200}
height={200}
className="w-full h-32 object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeImage(index)}
disabled={uploading}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
>
×
</button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{formatBytes(img.file.size)}
</div>
</div>
))}
</div>
)}
</div>
{/* 压缩包上传区 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
压缩包 * <span className="text-gray-500 text-xs">(支持 ZIP、RAR、7Z 格式)</span>
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isZipDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
onDragOver={(e) => handleDragOver(e, 'zip')}
onDragLeave={(e) => handleDragLeave(e, 'zip')}
onDrop={(e) => handleDrop(e, 'zip')}
>
<input
ref={zipInputRef}
type="file"
accept=".zip,.rar,.7z"
onChange={(e) => handleZipSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
type="button"
onClick={() => zipInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
选择压缩包
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
或拖拽压缩包到此区域
</p>
</div>
{/* 压缩包信息 */}
{zipFile && (
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="text-3xl">📦</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{zipFile.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatBytes(zipFile.size)}
</div>
</div>
</div>
<button
type="button"
onClick={removeZip}
disabled={uploading}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
>
删除
</button>
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传中...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onCancel}
disabled={uploading}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-apple hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
取消
</button>
<button
type="submit"
disabled={uploading}
className="px-6 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-apple hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? '上传中...' : '确认上传'}
</button>
</div>
</form>
</div>
);
}

View File

@ -5,7 +5,7 @@ export const PREVIEW_OPTIONAL_TYPES: string[] = [
'详情页',
'详情模板',
'模特',
'实拍图片',
'任务-实拍原图',
'买家秀图片',
'小红书种草',
'主图模版',

View File

@ -0,0 +1,239 @@
# 数据恢复行动方案
## 目标
恢复 `/mnt/PoolOne/apps/youyidian-3d/uploads` 目录中被删除的数据。
## 前提条件
- Ubuntu设备(Ubuntu 20.04或更高版本)
- 所有7块硬盘(WDC WD80EFBX 8TB)
- 足够的SATA接口或USB硬盘盒
- **至少2TB可用空间**(用于恢复的文件,实际需要恢复的数据约1.5TB)
## 方案1:ZFS存储池导入 + 深度扫描
### 步骤1:准备Ubuntu环境
```bash
# 1. 更新系统
sudo apt-get update
sudo apt-get upgrade -y
# 2. 安装ZFS工具
sudo apt-get install -y zfsutils-linux
# 3. 安装数据恢复工具
sudo apt-get install -y testdisk photorec foremost scalpel
# 4. 安装其他有用工具
sudo apt-get install -y zfs-dkms zfs-zed
```
### 步骤2:连接所有硬盘
**重要**:必须连接所有7块硬盘!
```bash
# 检查连接的硬盘
lsblk
# 查看硬盘ID(匹配TrueNAS中的硬盘)
ls -la /dev/disk/by-id/ | grep WDC_WD80EFBX
# 应该能看到7块硬盘的part2分区
```
### 步骤3:导入ZFS存储池
```bash
# 1. 查看可导入的存储池
sudo zpool import
# 应该能看到:
# pool: PoolOne
# id: 162731025200795572
# state: ONLINE
# 2. 导入存储池(使用-f强制,因为来自不同系统)
sudo zpool import -f PoolOne
# 3. 检查状态
sudo zpool status PoolOne
# 4. 查看挂载点
df -h | grep PoolOne
# 应该挂载在 /PoolOne 或 /mnt/PoolOne
```
### 步骤4:检查数据状态
```bash
# 查看uploads目录
ls -la /PoolOne/apps/youyidian-3d/uploads/
# 或
ls -la /mnt/PoolOne/apps/youyidian-3d/uploads/
# 检查目录大小
du -sh /PoolOne/apps/youyidian-3d/uploads/
```
### 步骤5:尝试ZFS历史记录
```bash
# 查看ZFS操作历史
sudo zfs history PoolOne | tail -100
# 查看特定数据集的历史(如果存在)
sudo zfs history PoolOne/apps/youyidian-3d 2>/dev/null || echo "数据集不存在"
```
## 方案2:底层块设备扫描(绕过ZFS)
### 步骤1:导出存储池
```bash
# 1. 卸载存储池
sudo zfs unmount PoolOne
# 2. 导出存储池(解除ZFS管理)
sudo zpool export PoolOne
```
### 步骤2:直接扫描块设备
**注意**:raidz2配置下,数据条带化分布,需要扫描所有硬盘。
```bash
# 识别所有硬盘设备
lsblk
# 假设硬盘设备为:sdc2, sdd2, sde2, sdf2, sdg2, sdh2, sdi2
# 对每块硬盘进行扫描(需要很长时间)
sudo photorec /dev/sdc2
sudo photorec /dev/sdd2
# ... 对每块硬盘重复
# 或使用testdisk
sudo testdisk /dev/sdc2
```
### 步骤3:重组raidz2数据
**关键问题**:即使扫描到数据块,也需要理解ZFS的raidz2结构才能重组。
这需要:
- 理解ZFS元数据结构
- 理解raidz2的条带化算法
- 可能需要专业的ZFS恢复工具
## 方案3:使用ZFS调试工具
### 步骤1:检查ZFS内部结构
```bash
# 导入存储池
sudo zpool import -f PoolOne
# 使用zdb查看ZFS内部结构
sudo zdb -l PoolOne
# 查看对象ID映射
sudo zdb -o PoolOne
# 查看数据集信息
sudo zdb -d PoolOne/apps/youyidian-3d
```
### 步骤2:尝试查找已删除的对象
```bash
# 列出所有对象
sudo zdb -o PoolOne | grep -i uploads
# 查看事务组信息
sudo zdb -u PoolOne
# 查看块指针信息
sudo zdb -bb PoolOne
```
## 方案4:使用专业ZFS恢复工具
### 选项1:ZFS Recover(如果存在)
```bash
# 搜索ZFS恢复工具
sudo apt-cache search zfs recover
# 或从源码编译(如果存在)
```
### 选项2:商业工具
- **R-Studio**:支持ZFS文件系统
- **UFS Explorer**:支持ZFS恢复
- **DiskInternals RAID Recovery**:支持raidz配置
## 方案5:手动ZFS元数据分析
这是最复杂但可能最有效的方法:
### 步骤1:导出ZFS元数据
```bash
# 导入存储池
sudo zpool import -f PoolOne
# 导出ZFS配置
sudo zdb -l PoolOne > /tmp/zfs-label.txt
# 导出对象信息
sudo zdb -o PoolOne > /tmp/zfs-objects.txt
# 导出事务组信息
sudo zdb -u PoolOne > /tmp/zfs-uberblock.txt
```
### 步骤2:分析元数据
需要分析:
- 文件系统的对象ID
- 目录结构和inode信息
- 数据块的指针信息
- 事务组的时间戳
### 步骤3:尝试恢复
基于元数据分析,尝试:
- 定位已删除的文件对象
- 重建目录结构
- 提取数据块
## 实际操作建议
### 立即执行的步骤
1. **准备Ubuntu设备**
2. **连接所有7块硬盘**
3. **导入ZFS存储池**
4. **检查数据状态**
5. **尝试所有恢复方法**
### 优先级
1. **方案1**:ZFS存储池导入 + 深度扫描(最安全)
2. **方案3**:ZFS调试工具(深入了解ZFS结构)
3. **方案5**:手动ZFS元数据分析(最复杂但可能最有效)
4. **方案2**:底层块设备扫描(风险高,作为最后手段)
5. **方案4**:专业工具(如果预算允许)
## 重要提示
1. **只读操作**:尽可能使用只读模式
2. **备份当前状态**:在操作前,如果可能,先备份当前状态
3. **记录所有操作**:记录每一步操作,便于回溯
4. **时间窗口**:删除后时间越短,恢复可能性越大
## 下一步
需要我详细说明哪个方案的具体操作步骤?

450
detailed-recovery-plan.md Normal file
View File

@ -0,0 +1,450 @@
# 数据恢复详细执行计划
## 目标
恢复 `/mnt/PoolOne/apps/youyidian-3d/uploads` 目录中被删除的数据。
## 当前状态确认
### 存储池信息
- **存储池名称**: PoolOne
- **配置类型**: raidz2(7块硬盘,可容忍2块硬盘故障)
- **存储池状态**: ONLINE(正常)
- **总容量**: 约50.5TB
- **已使用**: 约7.96TB
- **可用空间**: 约25.3TB
### 硬盘信息
7块 WDC WD80EFBX 8TB 硬盘:
1. ata-WDC_WD80EFBX-68AZZN0_VRGYN7GK-part2
2. ata-WDC_WD80EFBX-68AZZN0_VRGZW7VK-part2
3. ata-WDC_WD80EFBX-68AZZN0_VRGZT7LK-part2
4. ata-WDC_WD80EFBX-68AZZN0_VRGZVR2K-part2
5. ata-WDC_WD80EFBX-68AZZN0_VRH01V0K-part2
6. ata-WDC_WD80EFBX-68AZZN0_VRGZSHZK-part2
7. ata-WDC_WD80EFBX-68AZZN0_VRGSYK4K-part2
### uploads目录状态
- **路径**: `/mnt/PoolOne/apps/youyidian-3d/uploads`
- **当前状态**: 目录存在但为空(512字节)
- **数据丢失时间**: 2026年1月13日09:15
- **删除操作**: 由部署脚本的 `rm -rf` 或 `git clean -fd` 命令导致
### 数据集信息
- **类型**: filesystem(数据集,不是Zvol)
- **挂载点**: `/mnt/PoolOne`
- **快照状态**: 无快照可用
## 恢复方案详细步骤
## 方案1:Ubuntu设备 + ZFS存储池导入(最优先)⭐⭐⭐⭐⭐
### 前置条件检查清单
#### 硬件要求
- [ ] Ubuntu设备(Ubuntu 20.04或更高版本)
- [ ] 所有7块硬盘(必须全部连接)
- [ ] 足够的SATA接口(7个)或USB 3.0硬盘盒
- [ ] 足够的电源供应(7块8TB硬盘需要大量电源)
- [ ] **至少2TB可用空间**(用于恢复的文件,实际需要恢复的数据约1.5TB)
#### 软件要求
- [ ] Ubuntu系统已安装
- [ ] 网络连接(用于下载工具)
- [ ] root或sudo权限
### 详细执行步骤
#### 阶段1:Ubuntu环境准备
**步骤1.1:验证Ubuntu版本**
```bash
# 检查Ubuntu版本
lsb_release -a
# 要求:Ubuntu 20.04或更高版本
# 如果版本过低,需要升级或使用Live CD
```
**步骤1.2:安装ZFS工具**
```bash
# 更新软件包列表
sudo apt-get update
# 安装ZFS工具
sudo apt-get install -y zfsutils-linux
# 验证安装
zfs --version
zpool --version
# 预期输出:ZFS版本信息
```
**步骤1.3:安装数据恢复工具**
```bash
# 安装testdisk和photorec
sudo apt-get install -y testdisk
# 安装其他恢复工具
sudo apt-get install -y foremost scalpel
# 验证安装
testdisk --version
photorec --version
```
**步骤1.4:创建恢复工作目录**
```bash
# 创建工作目录
RECOVERY_DIR="/tmp/recovery_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$RECOVERY_DIR"
echo "恢复目录: $RECOVERY_DIR"
# 确保有足够空间(至少100GB)
df -h /tmp
```
#### 阶段2:硬盘连接和识别
**步骤2.1:连接所有硬盘**
**选项A:直接SATA连接**
- 将7块硬盘连接到Ubuntu设备的SATA接口
- 确保电源充足(每块硬盘约10W,7块约70W)
- 开机后检查设备
**选项B:USB硬盘盒(推荐)**
- 使用USB 3.0多盘位硬盘盒
- 或使用多个单盘位USB硬盘盒
- 确保USB 3.0接口(速度要求)
**步骤2.2:识别硬盘设备**
```bash
# 查看所有块设备
lsblk
# 预期输出:应该能看到7块硬盘(sdc, sdd, sde, sdf, sdg, sdh, sdi等)
# 查看硬盘ID(匹配TrueNAS中的硬盘ID)
ls -la /dev/disk/by-id/ | grep WDC_WD80EFBX
# 预期输出:应该能看到7块硬盘的part2分区,例如:
# ata-WDC_WD80EFBX-68AZZN0_VRGYN7GK-part2 -> ../../sdc2
# ata-WDC_WD80EFBX-68AZZN0_VRGZW7VK-part2 -> ../../sdd2
# ... (共7块)
# 记录设备映射关系
ls -la /dev/disk/by-id/ | grep WDC_WD80EFBX > "$RECOVERY_DIR/disk-mapping.txt"
cat "$RECOVERY_DIR/disk-mapping.txt"
```
**步骤2.3:验证所有硬盘已连接**
```bash
# 统计匹配的硬盘数量
DISK_COUNT=$(ls -la /dev/disk/by-id/ | grep -c "WDC_WD80EFBX.*part2")
echo "找到硬盘数量: $DISK_COUNT"
# 必须等于7
if [ "$DISK_COUNT" -eq 7 ]; then
echo "✅ 所有7块硬盘已连接"
else
echo "❌ 错误: 只找到 $DISK_COUNT 块硬盘,需要7块"
exit 1
fi
```
#### 阶段3:导入ZFS存储池
**步骤3.1:查看可导入的存储池**
```bash
# 查看可导入的存储池
sudo zpool import
# 预期输出:
# pool: PoolOne
# id: 162731025200795572
# state: ONLINE
# action: The pool can be imported using its name or numeric identifier.
```
**步骤3.2:导入存储池(只读模式尝试)**
```bash
# 先尝试只读导入(如果支持)
# 注意:zpool import 默认是读写模式,但我们可以先检查
# 查看导入选项
sudo zpool import -n PoolOne
# 实际导入(使用-f强制,因为来自不同系统)
sudo zpool import -f PoolOne
# 预期输出:存储池导入成功
```
**步骤3.3:验证存储池状态**
```bash
# 检查存储池状态
sudo zpool status PoolOne
# 预期输出:
# pool: PoolOne
# state: ONLINE
# status: 所有硬盘状态为ONLINE
# 检查存储池信息
sudo zpool list PoolOne
# 查看数据集列表
sudo zfs list -o name,type,used,avail,refer,mountpoint -r PoolOne | head -20
```
**步骤3.4:确认挂载点**
```bash
# 查看挂载点
df -h | grep PoolOne
# 可能的位置:
# /PoolOne 或 /mnt/PoolOne
# 检查uploads目录
if [ -d "/PoolOne/apps/youyidian-3d/uploads" ]; then
UPLOADS_PATH="/PoolOne/apps/youyidian-3d/uploads"
elif [ -d "/mnt/PoolOne/apps/youyidian-3d/uploads" ]; then
UPLOADS_PATH="/mnt/PoolOne/apps/youyidian-3d/uploads"
else
# 查找uploads目录
UPLOADS_PATH=$(find /PoolOne /mnt/PoolOne -type d -name "uploads" -path "*/youyidian-3d/*" 2>/dev/null | head -1)
fi
echo "uploads目录路径: $UPLOADS_PATH"
```
#### 阶段4:数据状态检查和分析
**步骤4.1:检查uploads目录状态**
```bash
# 查看目录内容
ls -lah "$UPLOADS_PATH"
# 查看目录大小
du -sh "$UPLOADS_PATH"
# 统计文件数量
find "$UPLOADS_PATH" -type f 2>/dev/null | wc -l
# 查看目录的inode信息
stat "$UPLOADS_PATH"
```
**步骤4.2:查看ZFS操作历史**
```bash
# 查看存储池的操作历史
sudo zfs history PoolOne > "$RECOVERY_DIR/zfs-history-full.txt"
# 查看最近100条记录
tail -100 "$RECOVERY_DIR/zfs-history-full.txt"
# 搜索与uploads或删除相关的操作
grep -i "upload\|delete\|rm\|clean" "$RECOVERY_DIR/zfs-history-full.txt" > "$RECOVERY_DIR/zfs-history-uploads.txt"
cat "$RECOVERY_DIR/zfs-history-uploads.txt"
```
**步骤4.3:导出ZFS元数据**
```bash
# 导出ZFS标签信息
sudo zdb -l PoolOne > "$RECOVERY_DIR/zfs-label.txt" 2>&1
# 导出ZFS对象信息(可能需要较长时间)
echo "正在导出ZFS对象信息(这可能需要很长时间)..."
sudo zdb -o PoolOne > "$RECOVERY_DIR/zfs-objects.txt" 2>&1 &
# 导出事务组信息
sudo zdb -u PoolOne > "$RECOVERY_DIR/zfs-uberblock.txt" 2>&1
# 导出数据集信息
sudo zdb -d PoolOne > "$RECOVERY_DIR/zfs-dataset.txt" 2>&1
echo "元数据导出完成,文件保存在: $RECOVERY_DIR"
```
**步骤4.4:分析ZFS元数据**
```bash
# 在对象信息中搜索uploads相关的内容
if [ -f "$RECOVERY_DIR/zfs-objects.txt" ]; then
echo "搜索uploads相关的对象..."
grep -i "upload" "$RECOVERY_DIR/zfs-objects.txt" | head -50 > "$RECOVERY_DIR/zfs-objects-uploads.txt"
cat "$RECOVERY_DIR/zfs-objects-uploads.txt"
fi
# 查看事务组信息(可能包含删除操作的时间戳)
if [ -f "$RECOVERY_DIR/zfs-uberblock.txt" ]; then
echo "查看事务组信息..."
head -100 "$RECOVERY_DIR/zfs-uberblock.txt"
fi
```
#### 阶段5:尝试恢复方法
**方法5.1:ZFS调试工具深度分析**
```bash
# 查看块指针信息
sudo zdb -bb PoolOne > "$RECOVERY_DIR/zfs-blocks.txt" 2>&1
# 查看数据集树结构
sudo zdb -P -t PoolOne > "$RECOVERY_DIR/zfs-tree.txt" 2>&1
# 尝试查找已删除的对象ID
# 这需要深入分析ZFS的内部结构
```
**方法5.2:文件系统级别扫描(如果ZFS允许)**
```bash
# 注意:ZFS限制了底层访问,此方法效果有限
# 在uploads目录中使用find查找可能的残留
find "$UPLOADS_PATH" -type f -name ".*" 2>/dev/null
# 检查是否有隐藏文件
find "$UPLOADS_PATH" -type f 2>/dev/null
```
**方法5.3:底层块设备扫描(最后手段)**
```bash
# ⚠️ 警告:此操作需要导出存储池,风险较高
# 1. 导出存储池
sudo zfs unmount PoolOne
sudo zpool export PoolOne
# 2. 对每块硬盘进行扫描
# 注意:raidz2配置下,单块硬盘无法完整恢复数据
# 识别设备(从之前的记录中获取)
# 假设设备为:sdc2, sdd2, sde2, sdf2, sdg2, sdh2, sdi2
# 使用photorec扫描(交互式)
# sudo photorec /dev/sdc2
# 或使用命令行模式
# sudo photorec /log /d /tmp/recovered /cmd /dev/sdc2 partition_none,fileopt,everything,enable,search
```
## 方案2:专业ZFS恢复工具
### 工具选项
#### 选项2.1:R-Studio(商业软件)
- **支持ZFS**: 是
- **支持raidz2**: 是
- **价格**: 约$80-900(根据版本)
- **使用方法**:
1. 在Ubuntu设备上安装R-Studio
2. 导入ZFS存储池
3. 使用ZFS恢复功能
#### 选项2.2:UFS Explorer(商业软件)
- **支持ZFS**: 是
- **支持raidz2**: 是
- **价格**: 约$50-500
- **使用方法**: 类似R-Studio
#### 选项2.3:专业数据恢复服务
- **优势**: 专业工具和技术
- **劣势**: 成本高(通常数万元)
- **适用**: 数据非常重要且预算充足
## 方案3:ZFS元数据手动分析(高级)
### 前提条件
- 深入理解ZFS文件系统结构
- 熟悉ZFS元数据格式
- 编程能力(可能需要编写脚本)
### 步骤概述
1. 导出所有ZFS元数据
2. 分析对象ID映射
3. 查找已删除文件的元数据
4. 重建目录结构
5. 提取数据块
## 不确定因素排查清单
### ✅ 已确认的信息
- [x] 存储池名称:PoolOne
- [x] 配置类型:raidz2(7块硬盘)
- [x] 存储池状态:ONLINE
- [x] uploads目录路径:/mnt/PoolOne/apps/youyidian-3d/uploads
- [x] 数据集类型:filesystem(不是Zvol)
- [x] 快照状态:无快照
- [x] 硬盘型号:WDC WD80EFBX 8TB
- [x] 数据丢失时间:2026年1月13日09:15
### ❓ 需要确认的信息
- [ ] Ubuntu设备的具体型号和配置
- [ ] Ubuntu设备的SATA接口数量
- [ ] 是否有USB硬盘盒可用
- [ ] Ubuntu设备的电源容量
- [ ] Ubuntu系统版本
- [ ] 是否有足够的存储空间(至少100GB)
- [ ] ZFS版本兼容性(TrueNAS使用的ZFS版本 vs Ubuntu的ZFS版本)
- [ ] 存储池导入后是否会自动挂载
- [ ] 存储池导入后的挂载点路径
### ⚠️ 风险和限制
- [ ] ZFS删除机制:ZFS不提供原生的undelete功能
- [ ] raidz2配置:数据条带化分布,需要所有硬盘
- [ ] 时间窗口:删除后时间越长,恢复可能性越低
- [ ] 写入操作:如果删除后有大量写入,数据可能已被覆盖
- [ ] ZFS版本兼容性:不同版本的ZFS可能有兼容性问题
## 执行优先级
### 第一优先级(立即执行)
1. ✅ **准备Ubuntu设备**
2. ✅ **连接所有7块硬盘**
3. ✅ **导入ZFS存储池**
4. ✅ **检查数据状态**
5. ✅ **查看ZFS历史记录**
### 第二优先级(深度分析)
1. ✅ **导出ZFS元数据**
2. ✅ **分析ZFS历史记录**
3. ✅ **搜索uploads相关对象**
### 第三优先级(尝试恢复)
1. ✅ **使用ZFS调试工具**
2. ✅ **分析ZFS元数据**
3. ✅ **尝试手动恢复**
### 第四优先级(最后手段)
1. ⚠️ **底层块设备扫描**(风险高)
2. ⚠️ **专业数据恢复工具/服务**
## 成功概率评估
| 方案 | 成功概率 | 原因 |
|------|---------|------|
| ZFS历史记录分析 | 10% | ZFS历史记录可能包含删除操作的线索,但无法直接恢复数据 |
| ZFS元数据分析 | 20% | 需要深入理解ZFS结构,可能找到已删除对象的元数据 |
| 底层块设备扫描 | 5% | raidz2配置和ZFS限制使恢复非常困难 |
| 专业工具/服务 | 60-80% | 专业工具和技术,但成本高 |
## 关键检查点
在执行每个阶段后,需要检查:
1. ✅ 所有硬盘是否正常识别
2. ✅ 存储池是否成功导入
3. ✅ uploads目录是否存在
4. ✅ ZFS历史记录是否包含有用信息
5. ✅ ZFS元数据是否成功导出
6. ✅ 是否有任何恢复线索
## 下一步行动
1. **确认Ubuntu设备准备情况**
2. **确认所有7块硬盘可以连接**
3. **开始执行方案1的阶段1和阶段2**
4. **根据检查结果决定后续步骤**

View File

@ -0,0 +1,164 @@
# 硬盘级数据恢复方案(无快照情况)
## 检查结果
经过检查,确认:
- ❌ **ZFS快照不包含uploads目录**(已确认)
- ❌ **数据恢复工具未安装**(testdisk, photorec, foremost, scalpel)
- ✅ **存储池状态正常**(PoolOne,raidz2配置,7块硬盘)
- ✅ **备份文件存在**(10月26日51GB备份)
## 硬盘级恢复方案
### 方案1:使用外部Linux系统进行底层恢复(推荐)⭐⭐⭐
**原理**:
- 将TrueNAS的硬盘连接到外部Linux系统
- 使用数据恢复工具(testdisk, photorec等)扫描底层块设备
- ZFS文件系统删除的文件,底层数据可能仍然存在(直到被覆盖)
**步骤**:
1. **准备外部系统**:
- 使用Live CD/USB(如Ubuntu Live、SystemRescue等)
- 或使用另一台Linux服务器
2. **连接硬盘**:
- 从TrueNAS系统中移除硬盘(或使用硬盘盒/USB连接)
- 连接到外部Linux系统
3. **安装恢复工具**:
```bash
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install testdisk photorec
# 或使用SystemRescue(已包含工具)
```
4. **扫描底层设备**:
```bash
# 识别设备
sudo fdisk -l
# 使用testdisk扫描
sudo testdisk /dev/sdX
# 或使用photorec直接恢复
sudo photorec /dev/sdX
```
**注意事项**:
- ⚠️ 需要专业知识和经验
- ⚠️ 操作前必须备份整个存储池
- ⚠️ 风险较高,可能损坏数据
### 方案2:在TrueNAS上创建临时Linux容器(实验性)
**限制**:
- TrueNAS系统限制包管理器
- 容器无法直接访问底层块设备
- ZFS文件系统限制了底层访问
**如果仍想尝试**:
```bash
# 使用Docker容器(之前已尝试,效果有限)
/root/docker-data-recovery.sh
```
### 方案3:专业数据恢复服务(最安全)⭐⭐⭐⭐⭐
**适用场景**:
- 数据非常重要
- 无法承受数据丢失风险
- 需要专业工具和技术
**服务内容**:
- 专业数据恢复实验室
- 底层块设备扫描
- ZFS文件系统恢复
- 数据完整性验证
**成本**:
- 通常按数据量和服务等级收费
- 一般需要数千到数万元
### 方案4:从备份恢复 + 接受数据丢失(实际方案)⭐⭐⭐⭐
**当前状况**:
- 10月26日备份可用(51GB)
- 10月27日至1月13日的数据可能无法恢复(约2.5个月)
**操作步骤**:
```bash
# 执行备份恢复脚本
/root/restore-uploads-from-backup.sh
```
**数据损失**:
- 可恢复:10月26日之前的所有数据
- 可能丢失:10月26日至1月13日之间上传的数据
## 技术分析
### ZFS删除机制
ZFS使用写时复制(Copy-on-Write)机制:
- 删除文件时,数据块被标记为可用
- 原数据可能仍然存在于磁盘上(直到被新数据覆盖)
- ZFS不提供原生的"undelete"功能
### 恢复可能性
**影响恢复的因素**:
1. **时间**:删除后是否有大量写入操作
2. **磁盘使用率**:磁盘是否接近满载
3. **ZFS特性**:raidz2的条带化可能影响恢复
4. **文件系统类型**:ZFS的复杂性增加了恢复难度
**实际情况**:
- uploads目录在1月13日09:15被清空
- 如果之后有大量写入,数据可能已被覆盖
- 如果写入较少,底层数据可能仍然存在
## 建议操作
### 立即操作
1. **停止所有写入操作**到该存储池(如果可能)
2. **从备份恢复**(10月26日备份)
3. **记录数据丢失范围**(10月26日至1月13日)
### 如果需要底层恢复
1. **评估数据价值**:
- 10月27日至1月13日的数据重要性
- 是否可以接受数据丢失
- 恢复成本是否值得
2. **选择恢复方式**:
- 如果数据非常重要 → 专业数据恢复服务
- 如果有技术能力 → 外部Linux系统恢复
- 如果可以接受损失 → 从备份恢复
3. **准备恢复环境**:
- 外部Linux系统
- 数据恢复工具
- 足够的存储空间
## 总结
**当前最佳方案**:
1. ✅ **立即从备份恢复**(10月26日备份)
2. ⚠️ **评估10月27日至1月13日数据的价值**
3. 💡 **如果数据非常重要,考虑专业数据恢复服务**
**技术限制**:
- ZFS文件系统限制底层恢复
- TrueNAS系统限制工具安装
- 无快照无法使用ZFS原生恢复
**实际建议**:
- 优先从备份恢复,确保系统正常运行
- 如果需要底层恢复,建议使用专业服务
- 未来启用ZFS快照功能,预防类似问题

View File

@ -67,6 +67,8 @@ services:
- VIDEO_TRANSCODE_PRESET=medium
- VIDEO_TRANSCODE_CRF=22
- VIDEO_AUDIO_BITRATE=160k
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=yydd-product-package-download-token-secret-2026
# Redis配置(影子系统使用独立的 Redis 实例)
- USE_REDIS=true
- REDIS_URL=redis://redis-shadow:6379

View File

@ -46,6 +46,8 @@ services:
- VIDEO_TRANSCODE_PRESET=medium
- VIDEO_TRANSCODE_CRF=22
- VIDEO_AUDIO_BITRATE=160k
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=yydd-product-package-download-token-secret-2026
volumes:
- ./uploads:/app/uploads
networks:

106
docker-data-recovery.sh Normal file
View File

@ -0,0 +1,106 @@
#!/bin/bash
# 使用Docker容器进行数据恢复尝试
# 在TrueNAS服务器上运行
# 注意:由于ZFS限制,只能进行文件系统层面的扫描
set -e
SCAN_DIR="/mnt/PoolOne/apps/youyidian-3d"
OUTPUT_DIR="/tmp/docker_recovery_$(date +%Y%m%d_%H%M%S)"
CONTAINER_NAME="data-recovery-$(date +%Y%m%d%H%M%S)"
echo "=========================================="
echo "Docker容器数据恢复脚本"
echo "=========================================="
echo ""
echo "扫描目录: $SCAN_DIR"
echo "输出目录: $OUTPUT_DIR"
echo "容器名称: $CONTAINER_NAME"
echo ""
echo "⚠️ 重要提示:"
echo " 1. 此操作只进行只读扫描,不会修改任何数据"
echo " 2. 由于ZFS文件系统限制,无法访问底层块设备"
echo " 3. 只能扫描当前文件系统中的文件"
echo " 4. 如果目录已被清空,恢复可能性很小"
echo ""
# 检查Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker不可用"
exit 1
fi
echo "✅ Docker可用"
echo ""
# 创建输出目录
mkdir -p "$OUTPUT_DIR"
echo "📦 步骤 1: 拉取Ubuntu镜像(包含基础工具)..."
docker pull ubuntu:22.04 >/dev/null 2>&1 || echo "使用本地镜像"
echo ""
echo "🔧 步骤 2: 创建临时容器并安装数据恢复工具..."
echo " 这可能需要几分钟..."
# 创建临时容器安装工具
docker run --rm --name "${CONTAINER_NAME}-setup" \
ubuntu:22.04 \
bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 && \
apt-get install -y -qq foremost scalpel testdisk binutils file >/dev/null 2>&1 && \
dpkg -l | grep -E 'foremost|scalpel|testdisk' | head -3
" || {
echo "⚠️ 警告: 工具安装可能失败,将使用基础工具"
}
echo ""
echo "🔍 步骤 3: 在容器内扫描文件系统..."
echo " 注意:由于uploads目录已清空,此扫描可能找不到已删除的文件"
echo ""
# 尝试使用容器扫描
docker run --rm --name "$CONTAINER_NAME" \
-v "$SCAN_DIR":/scan:ro \
-v "$OUTPUT_DIR":/output \
ubuntu:22.04 \
bash -c "
echo '检查扫描目录内容...'
ls -la /scan/uploads/ 2>&1 || echo 'uploads目录为空或不存在'
echo ''
echo '查找所有文件(包括隐藏文件)...'
find /scan -type f 2>/dev/null | head -50 > /output/all_files.txt 2>&1 || true
echo ''
echo '查找ZIP文件签名...'
find /scan -type f -exec file {} \; 2>/dev/null | grep -i zip > /output/zip_files.txt 2>&1 || true
echo ''
echo '扫描完成,结果保存在 /output'
"
echo ""
echo "📊 扫描结果:"
echo " 输出目录: $OUTPUT_DIR"
if [ -f "$OUTPUT_DIR/all_files.txt" ]; then
FILE_COUNT=$(wc -l < "$OUTPUT_DIR/all_files.txt")
echo " 找到文件数: $FILE_COUNT"
echo " 文件列表(前20个):"
head -20 "$OUTPUT_DIR/all_files.txt" | sed 's/^/ /'
fi
echo ""
echo "⚠️ 结论:"
echo " 由于ZFS文件系统的限制,Docker容器无法:"
echo " 1. 直接访问底层块设备"
echo " 2. 扫描已删除但未覆盖的数据块"
echo " 3. 进行真正的底层数据恢复"
echo ""
echo "💡 真实情况:"
echo " - uploads目录在1月13日09:15被清空"
echo " - ZFS删除文件后,数据块会被标记为可用"
echo " - 如果没有快照,恢复已删除的文件非常困难"
echo " - 容器内只能看到当前文件系统的状态(已清空)"
echo ""
echo "✅ 推荐方案:"
echo " 使用备份文件恢复(10月26日51GB备份)"
echo " 执行: ./restore-uploads-from-backup.sh"

View File

@ -68,8 +68,8 @@
### 步骤 1: 检查穿透服务器上的 nginx 配置
```bash
# 需要 SSH 到 180.97.221.140
ssh root@180.97.221.140
# 需要 SSH 到 180.97.221.140(端口9527)
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140
# 检查 nginx 状态
systemctl status nginx
@ -112,14 +112,14 @@ firewall-cmd --list-all # 如果使用 firewalld
### 方案 1: 重启 nginx(如果 nginx 配置正确)
```bash
ssh root@180.97.221.140
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140
systemctl restart nginx
systemctl status nginx
```
### 方案 2: 重启 frps 服务
```bash
ssh root@180.97.221.140
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140
systemctl restart frps
systemctl status frps
```
@ -285,7 +285,7 @@ systemctl restart frpc
netstat -an | grep 180.97.221.140:7000 | grep ESTABLISHED
# 4. 检查 frps 日志是否还有错误
ssh root@180.97.221.140 "tail -20 /var/log/frps.log | grep youyidian"
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "tail -20 /var/log/frps.log | grep youyidian"
# 5. 测试网站是否可以访问
curl -I https://3d.yydd.ai

View File

@ -15,7 +15,7 @@
在修改任何配置前,先备份:
```bash
ssh root@180.97.221.140
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140
mkdir -p /root/nginx-backup
cp -r /etc/nginx /root/nginx-backup/nginx-$(date +%Y%m%d_%H%M%S)
ls -la /root/nginx-backup/
@ -146,7 +146,7 @@ server {
### 步骤1: 创建配置文件
```bash
ssh root@180.97.221.140
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140
# 备份现有配置(如果还没备份)
mkdir -p /root/nginx-backup

View File

@ -28,13 +28,13 @@ ssh-keygen -t rsa -b 4096 -f $env:USERPROFILE\.ssh\id_rsa -N '""'
在Windows PowerShell中执行(需要输入密码 `NEjoy4h8B9Fa`):
```powershell
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh -p 22 root@180.97.221.140 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && chmod 700 ~/.ssh"
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh -p 9527 root@180.97.221.140 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && chmod 700 ~/.ssh"
```
### 步骤3:测试免密登录
```powershell
ssh -o StrictHostKeyChecking=no -p 22 root@180.97.221.140 "hostname"
ssh -o StrictHostKeyChecking=no -p 9527 root@180.97.221.140 "hostname"
```
如果不需要输入密码就能连接,说明配置成功!
@ -58,5 +58,5 @@ chmod 600 ~/.ssh/authorized_keys
配置成功后,以后可以直接使用以下命令连接,无需输入密码:
```powershell
ssh -p 22 root@180.97.221.140
ssh -p 9527 root@180.97.221.140
```

View File

@ -111,11 +111,13 @@ npx prisma generate
### 任务号格式
- 基础格式:`TASK` + 12-18位数字
- 子任务格式:`TASK` + 12-18位数字 + `-` + 2位数字
- 子任务格式:`TASK` + 12-18位数字 + `-` + 2位数字(可重复多个层级)
- 示例:
- 基础任务号(12位):`TASK202601030184`
- 基础任务号(18位):`TASK202601071437194808`
- 子任务号:`TASK202601071437194808-01`
- 一级子任务:`TASK202601071437194808-01`
- 二级子任务:`TASK202601141713525624-01-01`(任务号-子任务-子任务)
- 三级子任务:`TASK202601141713525624-01-01-01`(可无限层级)
- 唯一性:数据库级别唯一约束
### 存储策略

View File

@ -414,11 +414,13 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
### 任务号格式
- 基础格式:`TASK` + 12-18位数字
- 子任务格式:`TASK` + 12-18位数字 + `-` + 3位数字
- 子任务格式:`TASK` + 12-18位数字 + `-` + 2位数字(可重复多个层级)
- 示例:
- 基础任务号(12位):`TASK202601030184`
- 基础任务号(18位):`TASK202601071437194808`
- 子任务号:`TASK202601071437194808-001`
- 一级子任务:`TASK202601071437194808-01`
- 二级子任务:`TASK202601141713525624-01-01`(任务号-子任务-子任务)
- 三级子任务:`TASK202601141713525624-01-01-01`(可无限层级)
- 任务号在系统中唯一,重复上传会报错
### 任务状态说明

173
fix-nginx-configs.sh Normal file
View File

@ -0,0 +1,173 @@
#!/bin/bash
# 修复TrueNAS Nginx配置
# 1. 修复3d-yydd-ai.conf的语法错误
# 2. 修复default配置,使TrueNAS管理界面可访问
set -e
BACKUP_DIR="/root/nginx-backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "=========================================="
echo "修复TrueNAS Nginx配置"
echo "=========================================="
echo ""
# 创建备份目录
mkdir -p "$BACKUP_DIR"
# 备份配置文件
echo "📦 备份当前配置..."
cp /etc/nginx/sites-available/3d-yydd-ai.conf "$BACKUP_DIR/3d-yydd-ai.conf.backup-$TIMESTAMP" 2>/dev/null || true
cp /etc/nginx/sites-available/default "$BACKUP_DIR/default-backup-$TIMESTAMP" 2>/dev/null || true
echo "✅ 备份完成"
echo ""
# 修复3d-yydd-ai.conf
echo "✏️ 修复3d-yydd-ai.conf配置..."
cat > /etc/nginx/sites-available/3d-yydd-ai.conf << 'EOF'
# 3d.yydd.ai 统一访问地址配置
# 内网用户直接访问内网服务器
# HTTP重定向到HTTPS(仅对域名,IP访问不重定向)
server {
listen 80;
listen [::]:80;
server_name 3d.yydd.ai 3D.YYDD.AI www.3d.yydd.ai;
# 重定向到HTTPS
return 301 https://$server_name$request_uri;
}
# HTTPS配置
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name 3d.yydd.ai 3D.YYDD.AI www.3d.yydd.ai;
# SSL证书
ssl_certificate /etc/certificates/truenas_default.crt;
ssl_certificate_key /etc/certificates/truenas_default.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+ECDSA+AESGCM:EECDH+aRSA+AESGCM:EECDH+ECDSA:EDH+aRSA:EECDH:!RC4:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!PSK:!SRP:!DSS:!SHA1:!SHA256:!SHA384;
ssl_prefer_server_ciphers on;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
# 反向代理到素材库系统
location / {
proxy_pass http://127.0.0.1:4320;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering off;
proxy_request_buffering off;
}
}
EOF
echo "✅ 3d-yydd-ai.conf配置已修复"
echo ""
# 修复default配置
echo "✏️ 修复default配置..."
cat > /etc/nginx/sites-available/default << 'EOF'
##
# Default server configuration for IP access
#
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name _;
# TrueNAS管理界面路径 - 必须在其他location之前
location ~ ^/(ui|api)(/.*)?$ {
proxy_pass http://127.0.0.1:6000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
# WebSocket支持
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 其他请求转发到素材库系统
location / {
proxy_pass http://127.0.0.1:4320;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering off;
proxy_request_buffering off;
}
}
EOF
echo "✅ default配置已修复"
echo ""
# 测试Nginx配置
echo "🧪 测试Nginx配置..."
if nginx -t; then
echo "✅ Nginx配置测试通过"
else
echo "❌ Nginx配置测试失败,恢复备份..."
cp "$BACKUP_DIR/3d-yydd-ai.conf.backup-$TIMESTAMP" /etc/nginx/sites-available/3d-yydd-ai.conf 2>/dev/null || true
cp "$BACKUP_DIR/default-backup-$TIMESTAMP" /etc/nginx/sites-available/default 2>/dev/null || true
exit 1
fi
echo ""
# 重载Nginx
echo "🔄 重载Nginx..."
systemctl reload nginx
echo "✅ Nginx已重载"
echo ""
echo "=========================================="
echo "✅ 配置修复完成"
echo "=========================================="
echo ""
echo "现在可以通过以下方式访问TrueNAS管理界面:"
echo " - http://192.168.58.20/ui/"
echo " - https://192.168.58.20/ui/(如果配置了HTTPS)"
echo ""
echo "素材库系统仍然可以通过以下方式访问:"
echo " - http://192.168.58.20/"
echo " - https://3d.yydd.ai/"

107
fix-truenas-nginx-config.sh Normal file
View File

@ -0,0 +1,107 @@
#!/bin/bash
# 修复TrueNAS Nginx配置,使管理界面可访问
set -e
BACKUP_DIR="/root/nginx-backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "=========================================="
echo "修复TrueNAS Nginx配置"
echo "=========================================="
echo ""
# 创建备份目录
mkdir -p "$BACKUP_DIR"
# 备份当前default配置
echo "📦 备份当前配置..."
cp /etc/nginx/sites-available/default "$BACKUP_DIR/default-backup-$TIMESTAMP"
echo "✅ 备份完成: $BACKUP_DIR/default-backup-$TIMESTAMP"
echo ""
# 创建正确的default配置
echo "✏️ 创建正确的default配置..."
cat > /etc/nginx/sites-available/default << 'EOF'
##
# Default server configuration for IP access
#
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name _;
# TrueNAS管理界面路径 - 必须在其他location之前
location ~ ^/(ui|api)(/.*)?$ {
proxy_pass http://127.0.0.1:6000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
# WebSocket支持
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 其他请求转发到素材库系统
location / {
proxy_pass http://127.0.0.1:4320;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering off;
proxy_request_buffering off;
}
}
EOF
echo "✅ 配置文件已创建"
echo ""
# 测试Nginx配置
echo "🧪 测试Nginx配置..."
if nginx -t; then
echo "✅ Nginx配置测试通过"
else
echo "❌ Nginx配置测试失败,恢复备份..."
cp "$BACKUP_DIR/default-backup-$TIMESTAMP" /etc/nginx/sites-available/default
exit 1
fi
echo ""
# 重载Nginx
echo "🔄 重载Nginx..."
systemctl reload nginx
echo "✅ Nginx已重载"
echo ""
echo "=========================================="
echo "✅ 配置修复完成"
echo "=========================================="
echo ""
echo "现在可以通过以下方式访问TrueNAS管理界面:"
echo " - http://192.168.58.20/ui/"
echo " - https://192.168.58.20/ui/(如果配置了HTTPS)"
echo ""
echo "素材库系统仍然可以通过以下方式访问:"
echo " - http://192.168.58.20/"
echo " - https://3d.yydd.ai/"

130
fix-truenas-nginx-final.sh Normal file
View File

@ -0,0 +1,130 @@
#!/bin/bash
# 修复TrueNAS Nginx配置 - 最终版本
# TrueNAS的/ui/路径是静态文件,不是代理
set -e
BACKUP_DIR="/root/nginx-backup"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "=========================================="
echo "修复TrueNAS Nginx配置(最终版本)"
echo "=========================================="
echo ""
# 创建备份目录
mkdir -p "$BACKUP_DIR"
# 备份配置文件
echo "📦 备份当前配置..."
cp /etc/nginx/sites-available/default "$BACKUP_DIR/default-backup-$TIMESTAMP" 2>/dev/null || true
echo "✅ 备份完成"
echo ""
# 修复default配置 - TrueNAS的/ui/是静态文件,/api/是代理
echo "✏️ 修复default配置..."
cat > /etc/nginx/sites-available/default << 'EOF'
##
# Default server configuration for IP access
#
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name _;
# TrueNAS管理界面 - /ui/路径提供静态文件
location = /ui/ {
root /usr/share/truenas/webui;
try_files /index.html =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Expires 0;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
location /ui {
root /usr/share/truenas/webui;
try_files $uri $uri/ /index.html =404;
proxy_set_header X-Real-Remote-Addr $remote_addr;
proxy_set_header X-Https $https;
add_header Cache-Control "must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
# TrueNAS API - 代理到middleware
location ~ ^/api(/.*)?$ {
proxy_pass http://127.0.0.1:6000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
# WebSocket支持
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 其他请求转发到素材库系统
location / {
proxy_pass http://127.0.0.1:4320;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering off;
proxy_request_buffering off;
}
}
EOF
echo "✅ default配置已修复"
echo ""
# 测试Nginx配置
echo "🧪 测试Nginx配置..."
if nginx -t; then
echo "✅ Nginx配置测试通过"
else
echo "❌ Nginx配置测试失败,恢复备份..."
cp "$BACKUP_DIR/default-backup-$TIMESTAMP" /etc/nginx/sites-available/default 2>/dev/null || true
exit 1
fi
echo ""
# 重载Nginx
echo "🔄 重载Nginx..."
systemctl reload nginx
echo "✅ Nginx已重载"
echo ""
echo "=========================================="
echo "✅ 配置修复完成"
echo "=========================================="
echo ""
echo "现在可以通过以下方式访问TrueNAS管理界面:"
echo " - http://192.168.58.20/ui/"
echo " - https://192.168.58.20/ui/(如果配置了HTTPS)"
echo ""
echo "素材库系统仍然可以通过以下方式访问:"
echo " - http://192.168.58.20/"
echo " - https://3d.yydd.ai/"

305
free-zfs-recovery-tools.md Normal file
View File

@ -0,0 +1,305 @@
# 免费ZFS数据恢复工具
## 免费工具列表
### 工具1:TestDisk / PhotoRec(免费,开源)⭐⭐⭐
**类型**:通用数据恢复工具
**ZFS支持**:部分支持(作为文件系统)
**特点**:
- ✅ 完全免费,开源
- ✅ 支持多种文件系统
- ⚠️ ZFS支持有限(主要针对底层块设备)
- ⚠️ 对raidz2配置效果有限
**下载**:
- 官网:https://www.cgsecurity.org/wiki/TestDisk
- Ubuntu安装:`sudo apt-get install testdisk`
**使用方法**:
```bash
# PhotoRec(文件恢复)
sudo photorec /dev/sdX
# TestDisk(分区恢复)
sudo testdisk /dev/sdX
```
**限制**:
- 需要直接访问块设备
- 对ZFS文件系统支持有限
- 对raidz2配置效果有限
### 工具2:Foremost(免费,开源)⭐⭐
**类型**:文件雕刻工具
**ZFS支持**:不支持(底层文件签名扫描)
**特点**:
- ✅ 免费,开源
- ✅ 基于文件签名恢复
- ❌ 不支持ZFS文件系统结构
- ❌ 对raidz2配置无效
**下载**:
- Ubuntu安装:`sudo apt-get install foremost`
**限制**:
- 只能通过文件签名恢复
- 无法理解ZFS结构
- 对raidz2配置无效
### 工具3:Scalpel(免费,开源)⭐⭐
**类型**:文件雕刻工具
**ZFS支持**:不支持
**特点**:
- ✅ 免费,开源
- ✅ 基于文件签名恢复
- ❌ 不支持ZFS文件系统结构
**下载**:
- Ubuntu安装:`sudo apt-get install scalpel`
### 工具4:ZFS原生工具(免费,内置)⭐⭐⭐
**类型**:ZFS管理和调试工具
**ZFS支持**:完全支持
**特点**:
- ✅ 完全免费,ZFS自带
- ✅ 完全支持ZFS
- ⚠️ 没有原生的undelete功能
- ⚠️ 需要深入理解ZFS结构
**工具**:
- `zdb` - ZFS调试工具
- `zfs` - ZFS管理工具
- `zpool` - ZFS存储池工具
**使用方法**:
```bash
# 查看ZFS内部结构
sudo zdb -l PoolOne
sudo zdb -o PoolOne
sudo zdb -d PoolOne
sudo zdb -u PoolOne
# 查看操作历史
sudo zfs history PoolOne
```
**限制**:
- 没有原生的undelete功能
- 需要深入理解ZFS结构
- 需要手动分析和恢复
### 工具5:extundelete(免费,开源)❌
**类型**:ext文件系统恢复工具
**ZFS支持**:不支持
**说明**:只支持ext2/3/4文件系统,不支持ZFS
### 工具6:PhotoRec(TestDisk的一部分)⭐⭐
**类型**:文件恢复工具
**ZFS支持**:部分支持(底层扫描)
**特点**:
- ✅ 免费,开源
- ✅ 支持多种文件类型
- ⚠️ 对ZFS支持有限
### 工具7:ZFS Undelete脚本/工具(社区工具)⭐⭐
**搜索关键词**:zfs-undelete, zfs-recover
**说明**:
- 可能存在社区开发的ZFS恢复脚本
- 需要搜索和验证
- 可能不成熟或有限制
## 实际可用性分析
### 免费工具对ZFS恢复的效果
| 工具 | ZFS支持 | raidz2支持 | 恢复效果 | 推荐度 |
|------|---------|-----------|---------|--------|
| **ZFS原生工具(zdb)** | ✅ 完全支持 | ✅ 支持 | ⭐⭐⭐ | ⭐⭐⭐ |
| **PhotoRec/TestDisk** | ⚠️ 部分支持 | ❌ 不支持 | ⭐⭐ | ⭐⭐ |
| **Foremost/Scalpel** | ❌ 不支持 | ❌ 不支持 | ⭐ | ⭐ |
| **社区ZFS工具** | ⚠️ 未知 | ⚠️ 未知 | ⭐⭐ | ⭐⭐ |
### 关键问题
**免费工具的主要限制**:
1. **ZFS结构复杂性**:
- ZFS是复杂的文件系统
- 免费工具难以完全理解ZFS结构
- 特别是raidz2配置
2. **没有原生undelete功能**:
- ZFS本身没有原生的undelete功能
- 免费工具无法绕过这个限制
3. **raidz2配置限制**:
- 数据条带化分布在所有7块硬盘上
- 免费工具难以处理raidz2配置
## 实际建议
### 免费工具方案
**方案1:ZFS原生工具(zdb)+ 手动分析**⭐⭐⭐
**步骤**:
```bash
# 1. 导入存储池
sudo zpool import -f PoolOne
# 2. 使用zdb分析
sudo zdb -o PoolOne > /tmp/objects.txt
sudo zdb -d PoolOne > /tmp/dataset.txt
sudo zdb -u PoolOne > /tmp/uberblock.txt
# 3. 手动分析元数据
# 4. 尝试恢复
```
**优点**:
- ✅ 完全免费
- ✅ 完全支持ZFS
- ✅ 可以深入分析
**缺点**:
- ❌ 需要深入理解ZFS结构
- ❌ 没有原生的undelete功能
- ❌ 恢复难度高
**成功率**:10-20%
### 方案2:PhotoRec + 底层扫描(效果有限)⭐⭐
**步骤**:
```bash
# 1. 导出存储池
sudo zpool export PoolOne
# 2. 使用PhotoRec扫描块设备
sudo photorec /dev/sdX
```
**优点**:
- ✅ 免费
- ✅ 简单易用
**缺点**:
- ❌ 对ZFS支持有限
- ❌ 对raidz2配置无效
- ❌ 恢复效果差
**成功率**:5-10%
## 商业工具 vs 免费工具
### 商业工具(参考)
| 工具 | 价格 | ZFS支持 | 成功率 |
|------|------|---------|--------|
| R-Studio | $80-900 | ✅ 支持 | 60-80% |
| UFS Explorer | $50-500 | ✅ 支持 | 60-80% |
| DiskInternals RAID Recovery | $100-300 | ⚠️ 部分支持 | 40-60% |
### 免费工具
| 工具 | 价格 | ZFS支持 | 成功率 |
|------|------|---------|--------|
| ZFS原生工具(zdb) | 免费 | ✅ 完全支持 | 10-20% |
| PhotoRec/TestDisk | 免费 | ⚠️ 部分支持 | 5-10% |
| Foremost/Scalpel | 免费 | ❌ 不支持 | 1-5% |
## 市场现状(基于2025年最新信息)
### 关键发现
**重要结论**:
- ❌ **没有专门针对ZFS的免费数据恢复工具**
- ⚠️ 大多数专业ZFS恢复工具都是商业软件
- ✅ ZFS自带工具(zdb等)是最有效的免费选项
### 市场分析
根据最新调研:
1. **专业工具**:大多数是商业软件(R-Studio, UFS Explorer, ReclaiMe Pro等)
2. **免费工具**:通用工具(TestDisk, PhotoRec)对ZFS支持有限
3. **开源工具**:缺乏专门针对ZFS的开源恢复工具
4. **ZFS原生工具**:ZFS自带工具是唯一的免费且完全支持ZFS的选项
## 总结
### 免费ZFS恢复工具
**实际可用的免费工具(非常有限)**:
1. ✅ **ZFS原生工具(zdb等)** - 唯一有效的免费选项 ⭐⭐⭐
- 完全免费,ZFS自带
- 完全支持ZFS
- 需要深入理解ZFS结构
- 需要手动分析和恢复
- 成功率:10-20%
2. ⚠️ **PhotoRec/TestDisk** - 效果非常有限 ⭐
- 免费,开源
- 对ZFS支持非常有限
- 对raidz2配置无效
- 成功率:5-10%
3. ❌ **Foremost/Scalpel** - 不适用
- 免费但不支持ZFS
4. ❌ **通用恢复工具** - 不支持ZFS
- Recuva, EaseUS等主要支持NTFS/FAT
- 不支持ZFS文件系统
### 实际建议
**如果预算有限(免费方案)**:
1. ✅ **使用ZFS原生工具(zdb)+ 手动分析**
- 这是唯一有效的免费选项
- 需要学习ZFS结构
- 成功率:10-20%
2. ⚠️ **尝试PhotoRec(效果有限)**
- 对ZFS支持非常有限
- 成功率:5-10%
3. 💡 **学习ZFS结构**
- 提高使用zdb的成功率
- 参考ZFS官方文档和社区
**如果有预算(商业方案)**:
1. ✅ **考虑商业工具**
- R-Studio($80-900)
- UFS Explorer($50-500)
- ReclaiMe Pro(商业软件)
- 成功率:60-80%
2. ✅ **专业数据恢复服务**
- 有ZFS恢复经验的专业公司
- 成功率最高,但价格昂贵
### 关键结论
**免费ZFS恢复工具的现实**:
- ❌ **没有专门的免费ZFS恢复工具**
- ✅ **ZFS原生工具(zdb)是唯一有效的免费选项**
- ⚠️ **免费工具的成功率较低(10-20%)**
- 💰 **商业工具成功率更高(60-80%),但需要付费**
**建议**:
- 如果数据重要且预算允许,考虑商业工具或专业服务
- 如果预算有限,使用ZFS原生工具(zdb)+ 手动分析,成功率约10-20%

View File

@ -144,7 +144,7 @@ ps aux | grep frpc
netstat -an | grep 180.97.221.140:7000
# 等待几秒后,检查 frps 日志
ssh root@180.97.221.140 "tail -20 /var/log/frps.log | grep youyidian"
ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "tail -20 /var/log/frps.log | grep youyidian"
```
---

149
hard-disk-recovery-guide.md Normal file
View File

@ -0,0 +1,149 @@
# TrueNAS硬盘级数据恢复指南
## 情况说明
uploads目录在2026年1月13日09:15被清空,需要从硬盘层面进行数据恢复。
## TrueNAS系统特点
TrueNAS基于ZFS文件系统,具有以下特点:
- **写时复制(Copy-on-Write)**:数据修改不会直接覆盖原数据
- **事务日志**:所有操作都有事务记录
- **快照功能**:可以创建时间点快照
- **数据完整性校验**:自动检测和修复数据错误
## 恢复方案
### 方案1:ZFS快照恢复(最有效)⭐
如果启用了ZFS快照功能,可以恢复到删除前的状态。
**检查快照**:
```bash
# 查看所有快照
zfs list -t snapshot PoolOne/apps/youyidian-3d
# 查看特定时间点的快照
zfs list -t snapshot -o name,creation PoolOne/apps/youyidian-3d | grep "2026-01-13"
```
**恢复快照**(如果存在):
```bash
# 1. 列出可用快照
zfs list -t snapshot PoolOne/apps/youyidian-3d
# 2. 恢复到指定快照(替换SNAPSHOT_NAME)
zfs rollback PoolOne/apps/youyidian-3d@SNAPSHOT_NAME
# 或克隆快照到新位置查看
zfs clone PoolOne/apps/youyidian-3d@SNAPSHOT_NAME PoolOne/apps/youyidian-3d-recovered
```
### 方案2:ZFS事务日志恢复(实验性)
ZFS会记录所有操作的事务日志,但通常不提供直接的"undelete"功能。
**检查ZFS日志**:
```bash
# 查看ZFS事务日志(需要root权限)
zdb -l PoolOne
# 查看数据集的历史记录
zfs history PoolOne/apps/youyidian-3d | tail -100
```
### 方案3:底层块设备扫描(高级,风险高)
**⚠️ 警告**:此操作风险极高,可能导致数据永久丢失!
**前提条件**:
1. 系统必须有足够的空闲空间
2. 需要有专业的数据恢复知识
3. 建议先备份整个存储池
**步骤**:
1. **识别底层设备**:
```bash
# 查看ZFS池的底层设备
zpool status -v PoolOne
# 查看块设备
lsblk
```
2. **停止所有写入操作**(重要!):
```bash
# 卸载数据集
zfs unmount PoolOne/apps/youyidian-3d
# 或设置为只读
zfs set readonly=on PoolOne/apps/youyidian-3d
```
3. **使用数据恢复工具**:
- 需要从外部系统或Live CD/USB启动
- TrueNAS系统不允许安装数据恢复工具
- 建议使用专业数据恢复服务
### 方案4:从备份恢复(最安全)⭐
**可用备份**:
- 10月26日完整备份:51GB(`/mnt/PoolOne/backups/youyidian-3d/uploads/uploads_backup_20251026_133031.tar.gz`)
- 10月27日部分备份:3.4GB
**恢复步骤**:
```bash
# 使用恢复脚本
/root/restore-uploads-from-backup.sh
```
## TrueNAS管理界面操作
### 检查快照
1. **在TrueNAS Web界面中**:
- 导航到:**Storage** → **Snapshots**
- 查找 `PoolOne/apps/youyidian-3d` 的快照
- 检查1月13日09:15之前的快照
2. **如果找到快照**:
- 选择快照
- 点击 **Clone** 创建克隆数据集
- 或点击 **Rollback** 恢复到该快照(⚠️ 会丢失之后的数据)
### 查看存储池状态
1. **在TrueNAS Web界面中**:
- 导航到:**Storage** → **Pools**
- 查看 `PoolOne` 池的状态
- 检查是否有错误或警告
2. **查看数据集**:
- 导航到:**Storage** → **Datasets**
- 查看 `PoolOne/apps/youyidian-3d` 的详细信息
- 检查快照策略设置
## 重要提示
⚠️ **ZFS删除机制**:
- ZFS删除文件后,数据块会被标记为可用
- 如果没有快照,已删除的数据无法直接恢复
- ZFS不是为"undelete"设计的文件系统
⚠️ **数据恢复限制**:
- 如果数据已被新数据覆盖,无法恢复
- ZFS的写时复制机制意味着原数据可能仍然存在,但无法直接访问
- 需要专业工具或服务进行底层恢复
⚠️ **建议**:
1. **立即停止所有写入操作**到该数据集
2. **优先检查快照**(如果启用)
3. **从备份恢复**(最可靠)
4. **如需专业恢复**,联系数据恢复服务
## 下一步操作
1. 在TrueNAS管理界面检查快照
2. 如果无快照,执行备份恢复脚本
3. 如果确实需要底层恢复,考虑专业数据恢复服务

199
hard-disk-recovery-plan.md Normal file
View File

@ -0,0 +1,199 @@
# 硬盘级数据恢复方案(无快照)
## 检查结果确认
✅ **快照检查结果**:
- ❌ `PoolOne/apps/youyidian-3d` 不是独立的ZFS数据集
- ❌ `PoolOne` 根数据集没有快照
- ✅ 只有 `PoolOne/data2` 有快照(不包含uploads目录)
- ✅ 确认:**快照无法用于恢复uploads目录数据**
## 硬盘级恢复方案
### 方案1:外部Linux系统 + 数据恢复工具(推荐)⭐⭐⭐
**适用场景**:
- 有外部Linux系统或Live CD/USB
- 有数据恢复经验
- 数据重要性较高
**准备工作**:
1. **准备外部系统**:
- Ubuntu Live CD/USB(推荐)
- 或 SystemRescue CD(包含数据恢复工具)
- 或 另一台Linux服务器
2. **连接硬盘**:
- 从TrueNAS移除硬盘(或使用USB硬盘盒)
- 连接到外部Linux系统
3. **识别设备**:
```bash
sudo fdisk -l # 查看所有硬盘设备
lsblk # 查看块设备树
```
4. **安装恢复工具**(Ubuntu/Debian):
```bash
sudo apt-get update
sudo apt-get install testdisk photorec
```
5. **使用photorec恢复**(推荐,简单易用):
```bash
# 启动photorec
sudo photorec /dev/sdX
# 选择:
# - Partition: Whole disk
# - File system: Other (for ZFS)
# - Output directory: 选择有足够空间的目录
```
6. **使用testdisk恢复**(更高级):
```bash
sudo testdisk /dev/sdX
```
**注意事项**:
- ⚠️ ZFS是复杂的文件系统,恢复难度较高
- ⚠️ raidz2配置意味着数据条带化在多个硬盘上
- ⚠️ 需要恢复所有相关硬盘才能完整恢复数据
- ⚠️ 操作前必须备份整个存储池
### 方案2:专业数据恢复服务(最安全)⭐⭐⭐⭐⭐
**适用场景**:
- 数据非常重要,无法承受损失
- 缺乏数据恢复经验
- 需要专业工具和技术
**服务内容**:
- 专业数据恢复实验室
- ZFS文件系统恢复
- raidz2阵列恢复
- 数据完整性验证
**成本**:
- 通常按数据量和难度收费
- ZFS + raidz2恢复:可能需要数万元
- 恢复时间:几天到几周
### 方案3:从备份恢复 + 接受数据丢失(实际方案)⭐⭐⭐⭐
**当前状况**:
- ✅ 10月26日完整备份可用(51GB)
- ❌ 10月27日至1月13日的数据可能无法恢复
**操作步骤**:
```bash
# 执行备份恢复脚本
/root/restore-uploads-from-backup.sh
```
**数据损失评估**:
- 可恢复:10月26日之前的所有数据(51GB)
- 可能丢失:10月26日至1月13日之间上传的数据(约2.5个月)
### 方案4:TrueNAS Jail/插件系统(实验性)
**限制**:
- TrueNAS SCALE基于Linux,使用Docker/Kubernetes
- TrueNAS CORE使用Jails(FreeBSD)
- 需要确认TrueNAS版本
**如果TrueNAS SCALE(Linux)**:
- 可以尝试使用Docker容器(之前已尝试,效果有限)
- 容器无法直接访问底层块设备
**如果TrueNAS CORE(FreeBSD)**:
- 可以创建Jail并安装数据恢复工具
- 但ZFS限制仍然存在
## 技术分析
### ZFS + raidz2恢复挑战
1. **文件系统复杂性**:
- ZFS使用写时复制(Copy-on-Write)
- 数据组织方式复杂
- 元数据结构复杂
2. **raidz2配置**:
- 数据条带化在7块硬盘上
- 需要所有硬盘才能完整恢复
- 单块硬盘无法独立恢复数据
3. **删除机制**:
- 删除文件时,数据块被标记为可用
- 原数据可能仍然存在(直到被覆盖)
- ZFS不提供原生的"undelete"功能
### 恢复可能性评估
**有利因素**:
- ✅ 存储池使用率低(约1%)
- ✅ 删除后可能没有大量写入
- ✅ 数据块可能尚未被覆盖
**不利因素**:
- ❌ ZFS文件系统复杂性
- ❌ raidz2配置增加了恢复难度
- ❌ 时间已过去(1月13日删除,现在可能已覆盖)
## 推荐操作步骤
### 立即操作
1. **停止所有写入操作**(如果可能):
```bash
# 停止相关服务
docker stop youyidian-3d-material
# 或设置数据集为只读(如果可能)
```
2. **从备份恢复**(10月26日备份):
```bash
/root/restore-uploads-from-backup.sh
```
这样可以:
- ✅ 恢复51GB数据
- ✅ 确保系统正常运行
- ✅ 减少数据损失影响
### 如需底层恢复
1. **评估数据价值**:
- 10月27日至1月13日的数据重要性
- 恢复成本是否值得
- 是否可以接受数据丢失
2. **选择恢复方式**:
- **数据非常重要** → 专业数据恢复服务
- **有技术能力** → 外部Linux系统恢复
- **可以接受损失** → 从备份恢复
3. **准备恢复环境**(如果选择外部系统):
- 外部Linux系统或Live CD
- 足够的存储空间(至少100GB+)
- 数据恢复工具(testdisk, photorec)
## 总结
**当前最佳方案**:
1. ✅ **立即从备份恢复**(10月26日备份,51GB)
2. ⚠️ **评估10月27日至1月13日数据的价值**
3. 💡 **如果数据非常重要,考虑专业数据恢复服务**
**技术限制**:
- ZFS + raidz2恢复难度高
- TrueNAS系统限制工具安装
- 无快照无法使用ZFS原生恢复
**实际建议**:
- **优先从备份恢复**,确保系统正常运行
- **如需底层恢复**,建议使用专业数据恢复服务(因为ZFS + raidz2的复杂性)
- **未来启用快照功能**,为uploads目录创建定期快照

View File

@ -60,27 +60,35 @@ export interface MonthItem {
count: number
}
// 默认素材类型列表
// 默认素材类型列表(已分类排序)
export const DEFAULT_MATERIAL_TYPES = [
// 3D相关
'3D场景',
'材质',
'标准模型',
'标准饰品',
// 图片相关
'详情页',
'详情模板',
'材质',
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
// 视频相关
'主图视频',
'安装视频',
'种草视频',
// 模板和种草
'主图模版',
'SKU模版',
'小红书种草',
// 其他
'音乐',
'C4D模型',
'标准饰品',
'标准模型',
'品牌VI灵感',
'矢量文件',
// 任务相关
'任务-详情',
'任务-3D原图',
'任务-AI原图',
'任务-实拍原图',
]
export interface UseHomeStateReturn {

View File

@ -232,7 +232,7 @@ export function useSpecialBatchUpload(options: UseSpecialBatchUploadOptions) {
setError('')
try {
await uploadBatchVideo(batchVideoFiles, platformName, renderer, (progress) => {
await uploadBatchVideo(batchVideoFiles, platformName, renderer, '', (progress) => {
setUploadProgress(progress)
})

190
install-docker.ps1 Normal file
View File

@ -0,0 +1,190 @@
# Docker Desktop 安装前检查脚本
# 检查系统是否满足 Docker Desktop 安装要求
param(
[switch]$InstallWSL,
[switch]$Help
)
function Write-Success {
param([string]$Message)
Write-Host "✅ $Message" -ForegroundColor Green
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "❌ $Message" -ForegroundColor Red
}
function Write-Info {
param([string]$Message)
Write-Host "ℹ️ $Message" -ForegroundColor Cyan
}
function Write-Warning-Custom {
param([string]$Message)
Write-Host "⚠️ $Message" -ForegroundColor Yellow
}
function Show-Help {
Write-Host @"
Docker Desktop 安装前检查脚本
用法:
.\install-docker.ps1 # 检查系统要求
.\install-docker.ps1 -InstallWSL # 检查并安装 WSL 2(需要管理员权限)
功能:
- 检查 Windows 版本
- 检查 WSL 2 状态
- 检查虚拟化支持
- 提供安装指导
"@
}
function Main {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Docker Desktop 安装前检查" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
if ($Help) {
Show-Help
return
}
$allChecksPassed = $true
# 检查 1:Windows 版本
Write-Host "📋 检查 1:Windows 版本" -ForegroundColor Magenta
$osVersion = [System.Environment]::OSVersion.Version
$osInfo = Get-CimInstance Win32_OperatingSystem
Write-Info "操作系统:$($osInfo.Caption)"
Write-Info "版本:$($osVersion.Major).$($osVersion.Minor) Build $($osInfo.BuildNumber)"
if ($osVersion.Major -ge 10 -and $osVersion.Build -ge 19041) {
Write-Success "Windows 版本满足要求(Windows 10 2004 或更高)"
} else {
Write-Error-Custom "Windows 版本可能不满足要求(需要 Windows 10 2004 或更高)"
$allChecksPassed = $false
}
Write-Host ""
# 检查 2:WSL 状态
Write-Host "📋 检查 2:WSL 状态" -ForegroundColor Magenta
try {
$wslStatus = wsl --status 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Info "WSL 输出:"
Write-Host $wslStatus
# 检查 WSL 版本
$wslList = wsl --list --verbose 2>&1
if ($wslList -match "2.0") {
Write-Success "WSL 2 已安装"
} else {
Write-Warning-Custom "WSL 已安装,但可能不是 WSL 2"
Write-Info "建议运行:wsl --set-default-version 2"
}
} else {
Write-Warning-Custom "WSL 未安装或未正确配置"
Write-Info "需要安装 WSL 2 才能使用 Docker Desktop"
if ($InstallWSL) {
Write-Info "正在安装 WSL 2..."
# 检查是否有管理员权限
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error-Custom "安装 WSL 需要管理员权限"
Write-Info "请以管理员身份运行 PowerShell,然后执行:"
Write-Host " wsl --install" -ForegroundColor Yellow
Write-Host " wsl --set-default-version 2" -ForegroundColor Yellow
$allChecksPassed = $false
} else {
Write-Info "以管理员身份安装 WSL..."
wsl --install
if ($LASTEXITCODE -eq 0) {
Write-Success "WSL 安装命令已执行,请重启计算机后继续"
Write-Info "重启后运行:wsl --set-default-version 2"
} else {
Write-Error-Custom "WSL 安装失败"
$allChecksPassed = $false
}
}
} else {
Write-Info "要安装 WSL 2,请以管理员身份运行:"
Write-Host " wsl --install" -ForegroundColor Yellow
Write-Host " 或运行此脚本:.\install-docker.ps1 -InstallWSL" -ForegroundColor Yellow
$allChecksPassed = $false
}
}
} catch {
Write-Warning-Custom "无法检查 WSL 状态:$_"
$allChecksPassed = $false
}
Write-Host ""
# 检查 3:虚拟化支持
Write-Host "📋 检查 3:虚拟化支持" -ForegroundColor Magenta
try {
$hyperV = Get-CimInstance -ClassName Win32_ComputerSystem -Property HypervisorPresent
if ($hyperV.HypervisorPresent) {
Write-Success "检测到虚拟化支持(Hyper-V)"
} else {
Write-Warning-Custom "未检测到虚拟化支持"
Write-Info "请确保在 BIOS/UEFI 中启用了虚拟化功能(VT-x 或 AMD-V)"
}
} catch {
Write-Warning-Custom "无法检查虚拟化状态"
}
Write-Host ""
# 检查 4:内存
Write-Host "📋 检查 4:系统内存" -ForegroundColor Magenta
try {
$memory = Get-CimInstance Win32_ComputerSystem
$totalMemoryGB = [Math]::Round($memory.TotalPhysicalMemory / 1GB, 2)
Write-Info "总内存:$totalMemoryGB GB"
if ($totalMemoryGB -ge 8) {
Write-Success "内存充足(推荐 8GB 或更高)"
} elseif ($totalMemoryGB -ge 4) {
Write-Warning-Custom "内存可能不足(推荐 8GB,当前 $totalMemoryGB GB)"
} else {
Write-Error-Custom "内存不足(需要至少 4GB,当前 $totalMemoryGB GB)"
$allChecksPassed = $false
}
} catch {
Write-Warning-Custom "无法检查内存信息"
}
Write-Host ""
# 总结
Write-Host "========================================" -ForegroundColor Cyan
if ($allChecksPassed) {
Write-Success "系统检查通过!可以安装 Docker Desktop"
Write-Host ""
Write-Info "下一步:"
Write-Host " 1. 下载 Docker Desktop:" -ForegroundColor Yellow
Write-Host " https://www.docker.com/products/docker-desktop/" -ForegroundColor Cyan
Write-Host " 2. 运行安装程序并按照提示完成安装" -ForegroundColor Yellow
Write-Host " 3. 安装完成后重启计算机" -ForegroundColor Yellow
Write-Host " 4. 启动 Docker Desktop 并验证安装" -ForegroundColor Yellow
} else {
Write-Warning-Custom "系统检查未完全通过,请先解决上述问题"
Write-Host ""
Write-Info "常见问题解决:"
Write-Host " - WSL 未安装:以管理员身份运行 'wsl --install'" -ForegroundColor Yellow
Write-Host " - 虚拟化未启用:在 BIOS/UEFI 中启用虚拟化功能" -ForegroundColor Yellow
Write-Host " - 内存不足:关闭其他程序或增加内存" -ForegroundColor Yellow
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Info "详细安装指南请查看:Docker安装指南.md"
}
# 执行主函数
Main

View File

@ -0,0 +1,104 @@
#!/bin/bash
# 在TrueNAS上安装数据恢复工具的脚本
# 注意:TrueNAS系统可能限制包管理器使用
set -e
echo "=========================================="
echo "TrueNAS数据恢复工具安装脚本"
echo "=========================================="
echo ""
# 检查系统类型
echo "🔍 检查系统信息..."
OS_RELEASE=$(cat /etc/os-release 2>/dev/null | grep -i "ID=" | head -1)
echo "系统: $OS_RELEASE"
echo ""
# 检查apt是否可用
if ! command -v apt-get &> /dev/null; then
echo "❌ apt-get不可用"
echo "TrueNAS系统可能禁用了包管理器"
exit 1
fi
echo "✅ apt-get可用"
echo ""
# 尝试更新包列表
echo "📦 更新包列表..."
if apt-get update -qq 2>&1 | grep -q "E:"; then
echo "⚠️ 警告: 包列表更新可能失败,但继续尝试安装"
else
echo "✅ 包列表更新成功"
fi
echo ""
# 检查testdisk是否可用
echo "🔍 检查testdisk/photorec..."
if apt-cache search testdisk 2>&1 | grep -q "testdisk"; then
echo "✅ testdisk在仓库中可用"
PACKAGE_NAME=$(apt-cache search testdisk 2>&1 | grep "^testdisk " | awk '{print $1}')
echo " 包名: $PACKAGE_NAME"
else
echo "❌ testdisk在仓库中不可用"
exit 1
fi
echo ""
# 询问是否安装
echo "⚠️ 警告:"
echo " 1. TrueNAS系统可能不推荐安装额外软件"
echo " 2. 系统更新可能会覆盖这些工具"
echo " 3. 数据恢复工具在ZFS文件系统上效果有限"
echo ""
read -p "是否继续安装testdisk/photorec? (y/N) " CONFIRM
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
echo "❌ 安装已取消"
exit 0
fi
echo ""
echo "📦 安装testdisk/photorec..."
if apt-get install -y testdisk 2>&1; then
echo "✅ testdisk/photorec安装成功"
else
echo "❌ 安装失败"
echo ""
echo "可能的原因:"
echo " 1. TrueNAS系统限制了包管理器"
echo " 2. 网络连接问题"
echo " 3. 软件仓库不可用"
exit 1
fi
echo ""
echo "✅ 验证安装..."
if command -v testdisk &> /dev/null; then
TESTDISK_VERSION=$(testdisk -v 2>&1 | head -1)
echo "✅ testdisk: $TESTDISK_VERSION"
else
echo "❌ testdisk未正确安装"
fi
if command -v photorec &> /dev/null; then
PHOTOREC_VERSION=$(photorec -v 2>&1 | head -1)
echo "✅ photorec: $PHOTOREC_VERSION"
else
echo "❌ photorec未正确安装"
fi
echo ""
echo "=========================================="
echo "✅ 安装完成"
echo "=========================================="
echo ""
echo "⚠️ 重要提示:"
echo " 1. ZFS文件系统限制了底层数据恢复"
echo " 2. 这些工具在ZFS上的效果可能有限"
echo " 3. 建议优先使用备份恢复或专业服务"
echo ""
echo "使用方法:"
echo " sudo photorec /dev/sdX # 需要识别正确的设备"
echo " 注意:raidz2配置需要所有硬盘才能完整恢复"

221
iscsi-operation-guide.md Normal file
View File

@ -0,0 +1,221 @@
# TrueNAS iSCSI操作指南(数据恢复场景)
## ⚠️ 重要技术限制
根据TrueNAS文档,**iSCSI只能导出Zvol(ZFS卷,块设备)**,而**uploads目录位于数据集(文件系统)中**。
### 关键区别
- **数据集(Dataset)**:文件系统,存储文件(如 `/mnt/PoolOne/apps/youyidian-3d/uploads`)
- **Zvol(ZFS卷)**:块设备,用于iSCSI共享
### 问题
**uploads目录在文件系统中,无法通过iSCSI访问**:
- iSCSI只能导出Zvol(块设备)
- 无法将数据集转换为Zvol而不丢失数据
- 创建新的Zvol是空设备,不包含已删除的数据
**结论**:通过iSCSI无法直接恢复uploads目录的数据。
## 如果仍然想尝试iSCSI(学习用途)
虽然无法恢复uploads数据,但可以学习iSCSI和数据恢复工具的使用:
### 步骤1:在TrueNAS中创建Zvol
参考文档:https://www.truenas.com/docs/core/13.0/coretutorials/storage/pools/zvols/
1. **登录TrueNAS Web界面**:
- 访问:`https://192.168.58.20/ui/`
- 导航到:**Storage** → **Pools**
2. **创建Zvol**:
- 点击右上角的 **ADD ZVOL** 或 **+** 按钮
- 填写:
- **Name**: `recovery-test`
- **Size**: `100 GiB`(根据可用空间调整,建议至少50GB)
- 展开 **ADVANCED OPTIONS**:
- **Block Size**: 选择 **64k**(根据raidz2配置,7块硬盘建议64k)
- 点击 **SAVE**
### 步骤2:配置iSCSI共享
参考文档:https://www.truenas.com/docs/core/13.0/coretutorials/sharing/iscsi/addingiscsishare/
#### 2.1 启用iSCSI服务
1. 导航到:**Services** → **iSCSI**
2. 点击服务右侧的 **编辑**(铅笔图标)
3. 启用:
- 将开关切换到 **ON**
- 勾选 **Start Automatically**
4. 点击 **SAVE**
#### 2.2 创建iSCSI Target
1. 导航到:**Sharing** → **Block Shares (iSCSI)** → **Targets**
2. 点击 **ADD** 或 **+** 按钮
3. 填写:
- **Target Name**: `recovery-target`(自定义)
- 其他选项使用默认值
4. 点击 **SAVE**
#### 2.3 创建iSCSI Extent
1. 导航到:**Sharing** → **Block Shares (iSCSI)** → **Extents**
2. 点击 **ADD** 或 **+** 按钮
3. 填写:
- **Name**: `recovery-extent`
- **Type**: 选择 **Device**
- **Device**: 选择 `PoolOne/recovery-test`(刚才创建的Zvol)
- **Logical Block Size**: `512`
- 其他选项使用默认值
4. 点击 **SAVE**
#### 2.4 关联Target和Extent
1. 导航到:**Sharing** → **Block Shares (iSCSI)** → **Target Extents**
2. 点击 **ADD** 或 **+** 按钮
3. 填写:
- **Target**: 选择 `recovery-target`
- **Extent**: 选择 `recovery-extent`
- **LUN ID**: `0`(默认)
4. 点击 **SAVE**
### 步骤3:在VM中连接iSCSI设备
**VM环境准备**(Ubuntu 22.04/Debian 12):
```bash
# 1. 更新系统
sudo apt-get update
# 2. 安装iSCSI客户端
sudo apt-get install open-iscsi
# 3. 启动iSCSI服务
sudo systemctl start iscsid
sudo systemctl enable iscsid
# 4. 发现iSCSI目标(TrueNAS IP: 192.168.58.20)
sudo iscsiadm -m discovery -t st -p 192.168.58.20
# 输出示例:
# 192.168.58.20:3260,1 iqn.2005-10.org.freenas.ctl:recovery-target
# 5. 登录iSCSI目标(替换为实际的IQN,从第4步的输出中获取)
sudo iscsiadm -m node -T iqn.2005-10.org.freenas.ctl:recovery-target -p 192.168.58.20 -l
# 6. 查看新出现的块设备
lsblk
# 应该能看到新的设备,例如:
# sdb 8:16 0 100G 0 disk
# 注意设备名称(例如 /dev/sdb)
# 7. 安装数据恢复工具
sudo apt-get install testdisk
# 8. 使用photorec恢复(注意:这是新的空设备,只能用于测试工具)
sudo photorec /dev/sdb
```
### 步骤4:使用photorec(测试)
```bash
# 启动photorec
sudo photorec /dev/sdb
# 操作步骤:
# 1. 选择磁盘:[Proceed]
# 2. 选择分区表类型:[Intel](大多数情况下)
# 3. 选择操作:[Search](搜索已删除的文件)
# 4. 选择文件系统:[Whole](扫描整个设备)
# 5. 选择文件类型:[Free](选择所有类型)或选择特定类型
# 6. 选择输出目录:选择有足够空间的目录
# 7. 开始恢复:[C] 继续
```
**注意**:由于这是新的空Zvol,不会找到任何已删除的文件,只能用于测试工具的使用方法。
## 实际数据恢复方案
由于iSCSI无法访问文件系统级别的数据(uploads目录),实际的数据恢复方案是:
### 方案1:从备份恢复(推荐)⭐⭐⭐⭐⭐
```bash
# 执行备份恢复脚本
/root/restore-uploads-from-backup.sh
```
**优点**:
- ✅ 安全可靠
- ✅ 立即可用
- ✅ 恢复51GB数据(10月26日备份)
**缺点**:
- ❌ 只能恢复备份时间点的数据
- ❌ 10月27日至1月13日的数据可能无法恢复
### 方案2:专业数据恢复服务⭐⭐⭐⭐⭐
**适用场景**:
- 10月27日至1月13日的数据非常重要
- 无法接受数据丢失
- 有足够的预算
**优点**:
- ✅ 专业工具和技术
- ✅ 恢复成功率较高
- ✅ 不修改TrueNAS系统
**缺点**:
- ❌ 成本较高(通常数万元)
- ❌ 需要时间(几天到几周)
### 方案3:外部Linux系统 + 直接访问硬盘⭐⭐⭐
**步骤**:
1. 从TrueNAS移除硬盘(或使用USB硬盘盒)
2. 连接到外部Linux系统
3. 使用数据恢复工具直接扫描硬盘
**优点**:
- ✅ 直接访问物理硬盘
- ✅ 恢复效果最好
- ✅ 成本低
**缺点**:
- ❌ 需要拆硬盘
- ❌ 需要技术能力
- ❌ 可能影响TrueNAS运行
## 总结
### iSCSI方案的限制
- ❌ **无法通过iSCSI访问文件系统级别的数据**
- ❌ **无法恢复uploads目录中已删除的文件**
- ✅ **只能用于创建新的块设备**(学习用途)
### 实际建议
1. ✅ **立即从备份恢复**(10月26日备份,51GB)
2. ⚠️ **评估10月27日至1月13日数据的价值**
3. 💡 **如需恢复更多数据,考虑专业数据恢复服务**
### 如果仍想尝试iSCSI
**目的**:学习iSCSI和数据恢复工具的使用方法
**步骤**:按照上述步骤操作,但请注意:
- 无法恢复uploads目录的数据
- 只能用于测试和学习
- 创建的Zvol是新的空设备
## 参考文档
- TrueNAS Zvols文档:https://www.truenas.com/docs/core/13.0/coretutorials/storage/pools/zvols/
- TrueNAS iSCSI共享文档:https://www.truenas.com/docs/core/13.0/coretutorials/sharing/iscsi/addingiscsishare/

130
iscsi-recovery-analysis.md Normal file
View File

@ -0,0 +1,130 @@
# iSCSI功能数据恢复可行性分析
## 关键问题分析
从你提供的TrueNAS iSCSI向导界面来看,你在选择 `/mnt/PoolOne/apps/youyidian-3d` 数据集。
### iSCSI向导的工作原理
当你选择数据集路径时,iSCSI向导会:
1. **检查路径类型**:
- 如果是数据集(文件系统)→ 会要求**创建新的Zvol**
- 如果是已存在的Zvol → 可以直接使用
2. **创建Zvol的过程**:
- 在选定的数据集下创建一个新的Zvol(块设备)
- Zvol是**全新的空块设备**
- **不包含数据集中的文件数据**
### 为什么无法通过iSCSI恢复数据?
#### 核心原因
1. **数据集 vs Zvol的区别**:
- `/mnt/PoolOne/apps/youyidian-3d` 是**数据集(文件系统)**
- iSCSI只能导出**Zvol(块设备)**
- 数据集和Zvol是ZFS中两种不同的存储类型
2. **数据存储方式不同**:
- 数据集:存储文件,有目录结构
- Zvol:块设备,像硬盘分区,没有文件系统结构
3. **创建新Zvol的影响**:
- 如果通过iSCSI向导创建Zvol
- 会创建一个**新的空块设备**
- uploads目录的数据在数据集中,不在Zvol中
- **新创建的Zvol不包含已删除的数据**
## 技术验证
让我们验证一下:
```bash
# 检查youyidian-3d是什么类型
zfs list -o name,type /mnt/PoolOne/apps/youyidian-3d
# 如果是filesystem(数据集),则无法直接用于iSCSI
# 如果是volume(Zvol),则可以用于iSCSI
```
## 可能的误解
你可能在想:
1. **能否通过iSCSI访问数据集?**
- ❌ 不能,iSCSI只能导出Zvol
2. **能否将数据集转换为Zvol?**
- ❌ 不能,会丢失所有数据
3. **能否创建Zvol后访问底层数据?**
- ❌ 新Zvol是空设备,不包含数据集的数据
## 如果真的想尝试(不推荐)
如果你仍然想尝试,可以:
1. **创建Zvol**(会占用存储空间,但不包含已删除数据):
- 在iSCSI向导中创建Zvol
- 但这无法恢复uploads目录的数据
2. **在VM中连接iSCSI设备**:
- 连接到新创建的Zvol
- 但这是空设备,无法恢复数据
## 实际可行的恢复方案
由于iSCSI无法恢复文件系统级别的数据,实际可行的方案是:
### 方案1:ZFS存储池导入(Ubuntu设备)
**步骤**:
1. 在Ubuntu设备上安装ZFS工具
2. 连接所有7块硬盘
3. 导入ZFS存储池:`sudo zpool import -f PoolOne`
4. 检查数据状态和ZFS历史记录
5. 尝试ZFS元数据分析
**这是最可能成功的方法**。
### 方案2:ZFS元数据深度分析
**步骤**:
1. 导入存储池后,使用zdb工具分析
2. 查看ZFS内部对象结构
3. 查找已删除文件的元数据
4. 尝试恢复数据块
### 方案3:专业ZFS恢复工具
**工具**:
- R-Studio(支持ZFS)
- UFS Explorer(支持ZFS)
- 专业数据恢复服务
## 结论
### iSCSI功能无法恢复数据的原因
1. ✅ **iSCSI只能导出Zvol(块设备)**
2. ✅ **uploads目录在数据集(文件系统)中**
3. ✅ **创建新Zvol是空设备,不包含数据集数据**
4. ✅ **无法通过iSCSI访问文件系统级别的数据**
### 实际可行的恢复路径
1. ✅ **Ubuntu设备 + ZFS存储池导入**(最可行)
2. ✅ **ZFS元数据深度分析**(技术难度高)
3. ✅ **专业ZFS恢复工具/服务**(成本高但最可靠)
## 建议
**不要通过iSCSI向导创建Zvol**,因为:
- 无法恢复数据
- 会占用存储空间
- 浪费时间
**应该**:
1. 使用Ubuntu设备导入ZFS存储池
2. 分析ZFS历史和元数据
3. 尝试所有可能的恢复方法

198
iscsi-recovery-guide.md Normal file
View File

@ -0,0 +1,198 @@
# TrueNAS iSCSI配置和数据恢复指南
## ⚠️ 重要限制说明
根据TrueNAS文档,iSCSI使用**Zvol(ZFS卷)**作为块设备。但有一个关键问题:
### Zvol vs 数据集的区别
- **数据集(Dataset)**:文件系统,存储文件(如uploads目录)
- **Zvol(ZFS卷)**:块设备,用于iSCSI共享
### 关键问题
**uploads目录位于数据集(文件系统)中,不是Zvol(块设备)**:
- uploads目录在 `/mnt/PoolOne/apps/youyidian-3d/uploads`
- 这是文件系统,不是块设备
- iSCSI只能导出Zvol(块设备),不能导出数据集(文件系统)
### 对数据恢复的影响
1. **无法直接通过iSCSI访问uploads目录**:
- uploads目录是文件系统的一部分
- iSCSI只能导出块设备(Zvol)
- 无法将数据集转换为Zvol而不丢失数据
2. **如果创建新的Zvol**:
- 这是一个全新的空块设备
- 不包含uploads目录的数据
- 无法用于恢复已删除的文件
## 可行的iSCSI方案(实验性)
虽然无法直接通过iSCSI访问uploads目录,但可以尝试以下方案:
### 方案1:创建Zvol并测试数据恢复工具(学习用途)
**目的**:在VM中测试数据恢复工具的使用方法
**步骤**:
#### 1. 在TrueNAS中创建Zvol
参考文档:https://www.truenas.com/docs/core/13.0/coretutorials/storage/pools/zvols/
1. **登录TrueNAS Web界面**:
- 访问:`https://192.168.58.20/ui/`
- 导航到:**Storage** → **Pools**
2. **创建Zvol**:
- 选择 `PoolOne` 池
- 点击 **ADD ZVOL**(或右上角的 **+** 按钮)
- 配置:
- **Name**: `recovery-test`(测试用名称)
- **Size**: `100 GiB`(根据可用空间调整)
- **Block Size**: 根据raidz2配置,7块硬盘建议使用 **64k**(参考文档表格)
- 点击 **SAVE**
#### 2. 配置iSCSI共享
参考文档:https://www.truenas.com/docs/core/13.0/coretutorials/sharing/iscsi/addingiscsishare/
1. **启用iSCSI服务**:
- 导航到:**Services** → **iSCSI**
- 点击服务旁边的 **编辑**(铅笔图标)
- 启用服务:将开关切换到 **ON**
- 选择 **Start Automatically**(自动启动)
- 点击 **SAVE**
2. **配置iSCSI Target**:
- 导航到:**Sharing** → **Block Shares (iSCSI)** → **Targets**
- 点击 **ADD**(或 **+** 按钮)
- 配置:
- **Target Name**: `recovery-target`(自定义名称)
- 其他选项使用默认值
- 点击 **SAVE**
3. **创建Extent**:
- 导航到:**Sharing** → **Block Shares (iSCSI)** → **Extents**
- 点击 **ADD**(或 **+** 按钮)
- 配置:
- **Name**: `recovery-extent`
- **Type**: 选择 **Device**
- **Device**: 选择刚才创建的Zvol `PoolOne/recovery-test`
- **Logical Block Size**: `512`
- 其他选项使用默认值
- 点击 **SAVE**
4. **关联Target和Extent**:
- 导航到:**Sharing** → **Block Shares (iSCSI)** → **Target Extents**
- 点击 **ADD**(或 **+** 按钮)
- 配置:
- **Target**: 选择 `recovery-target`
- **Extent**: 选择 `recovery-extent`
- **LUN ID**: `0`(默认)
- 点击 **SAVE**
#### 3. 在VM中连接iSCSI设备
**VM环境**(Ubuntu/Debian):
```bash
# 1. 安装iSCSI客户端
sudo apt-get update
sudo apt-get install open-iscsi
# 2. 启动iSCSI服务
sudo systemctl start iscsid
sudo systemctl enable iscsid
# 3. 发现iSCSI目标
sudo iscsiadm -m discovery -t st -p 192.168.58.20
# 输出应该类似:
# 192.168.58.20:3260,1 iqn.2005-10.org.freenas.ctl:recovery-target
# 4. 登录iSCSI目标(替换为实际的IQN)
sudo iscsiadm -m node -T iqn.2005-10.org.freenas.ctl:recovery-target -p 192.168.58.20 -l
# 5. 查看新出现的块设备
lsblk
# 应该能看到新的设备,例如 /dev/sdb 或 /dev/sdc
# 注意设备名称,例如 /dev/sdb
# 6. 安装数据恢复工具
sudo apt-get install testdisk
# 7. 使用photorec恢复(这只是测试,因为是新的空设备)
sudo photorec /dev/sdb
```
### 方案2:导出整个存储池作为Zvol(不推荐)
**限制**:
- 需要将整个存储池导出为块设备
- 操作复杂,风险高
- 可能影响现有数据
- TrueNAS不直接支持这种方式
## 实际建议
### ⚠️ iSCSI方案的局限性
通过iSCSI无法直接恢复uploads目录的数据,因为:
1. **uploads目录在文件系统中**,不是块设备
2. **iSCSI只能导出Zvol(块设备)**,不能导出数据集
3. **创建新的Zvol是空设备**,不包含已删除的数据
### ✅ 推荐的实际方案
由于iSCSI无法访问文件系统级别的数据,建议:
1. **从备份恢复**(最实际):
```bash
/root/restore-uploads-from-backup.sh
```
- 恢复10月26日备份(51GB)
- 安全可靠,立即可用
2. **专业数据恢复服务**:
- 如果需要恢复10月27日至1月13日的数据
- 专业工具可以直接访问存储池
- 成本较高但最可靠
3. **外部Linux系统 + 直接访问硬盘**:
- 从TrueNAS移除硬盘
- 连接到外部Linux系统
- 使用数据恢复工具直接扫描硬盘
## 总结
### iSCSI方案的限制
- ❌ **无法通过iSCSI访问文件系统级别的数据**
- ❌ **无法恢复uploads目录中已删除的文件**
- ✅ **只能用于创建新的块设备**(不包含已删除数据)
### 如果仍想尝试iSCSI
**目的**:学习和测试数据恢复工具的使用方法
**步骤**:
1. 在TrueNAS中创建Zvol
2. 配置iSCSI共享
3. 在VM中连接iSCSI设备
4. 测试数据恢复工具(但无法恢复uploads数据)
### 实际数据恢复建议
1. ✅ **从备份恢复**(10月26日备份)
2. ⚠️ **专业数据恢复服务**(如需恢复更多数据)
3. ❌ **不建议使用iSCSI进行数据恢复**(技术限制)
## 参考文档
- TrueNAS Zvols文档:https://www.truenas.com/docs/core/13.0/coretutorials/storage/pools/zvols/
- TrueNAS iSCSI共享文档:https://www.truenas.com/docs/core/13.0/coretutorials/sharing/iscsi/addingiscsishare/

View File

@ -92,28 +92,35 @@ export async function canAddSuperAdmin(): Promise<boolean> {
// 默认素材类型列表(作为后备,用于新部门创建默认权限配置时)
// 如果数据库中已有素材,会优先使用数据库中的素材类型
// 已按分类排序
export const DEFAULT_MATERIAL_TYPES = [
// 3D相关
'3D场景',
'材质',
'标准模型',
'标准饰品',
// 图片相关
'详情页',
'详情模板',
'材质',
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
// 视频相关
'主图视频',
'安装视频',
'种草视频',
// 模板和种草
'主图模版',
'SKU模版',
'小红书种草',
// 其他
'音乐',
'C4D模型',
'标准饰品',
'标准模型',
'品牌VI灵感',
'矢量文件',
'CAD',
'产品全案',
// 任务相关
'任务-详情',
'任务-3D原图',
'任务-AI原图',
'任务-实拍原图',
] as const;
/**

Some files were not shown because too many files have changed in this diff Show More