源码备份:反编译还原完成后的完整源码 (2026-01-16)
This commit is contained in:
parent
562b7c6cdb
commit
6e4e72483b
130
.dockerignore
130
.dockerignore
@ -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
|
||||
|
||||
@ -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
205
Docker安装指南.md
Normal 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
437
GUID引导重建指南.md
Normal 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. **验证**:确认系统可以正常启动
|
||||
|
||||
记住:**数据安全第一,操作前务必备份!**
|
||||
678
TrueNAS关机与硬盘操作安全指南.md
Normal file
678
TrueNAS关机与硬盘操作安全指南.md
Normal 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)
|
||||
210
UFS-Explorer-数据恢复操作指南.md
Normal file
210
UFS-Explorer-数据恢复操作指南.md
Normal 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
|
||||
196
UFS-Explorer-界面翻译对照表.md
Normal file
196
UFS-Explorer-界面翻译对照表.md
Normal 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
|
||||
982
USB硬盘盒数据恢复详细计划.md
Normal file
982
USB硬盘盒数据恢复详细计划.md
Normal 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系统
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
96
app/api/external/download/route.ts
vendored
96
app/api/external/download/route.ts
vendored
@ -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) {
|
||||
|
||||
174
app/api/external/product-assets/route.ts
vendored
174
app/api/external/product-assets/route.ts
vendored
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
// 记录日志
|
||||
|
||||
@ -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:按文件大小阈值)
|
||||
|
||||
413
app/api/materials/upload/3d-original/complete/route.ts
Normal file
413
app/api/materials/upload/3d-original/complete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/api/materials/upload/3d-original/init/route.ts
Normal file
95
app/api/materials/upload/3d-original/init/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
413
app/api/materials/upload/ai-original/complete/route.ts
Normal file
413
app/api/materials/upload/ai-original/complete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/api/materials/upload/ai-original/init/route.ts
Normal file
95
app/api/materials/upload/ai-original/init/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
// }
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
413
app/api/materials/upload/real-photo/complete/route.ts
Normal file
413
app/api/materials/upload/real-photo/complete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/api/materials/upload/real-photo/init/route.ts
Normal file
95
app/api/materials/upload/real-photo/init/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
84
app/api/test-download/route.ts
Normal file
84
app/api/test-download/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/api/upload/s3-original/route.ts
Normal file
66
app/api/upload/s3-original/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 });
|
||||
|
||||
25
app/page.tsx
25
app/page.tsx
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
73
check-truenas-iscsi.sh
Normal 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
100
check-zfs-snapshots.sh
Normal 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 "=========================================="
|
||||
638
complete-recovery-execution-plan.md
Normal file
638
complete-recovery-execution-plan.md
Normal 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. **专业帮助**:如果遇到困难,考虑专业数据恢复服务
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 产品全案详情组件
|
||||
* 任务-详情详情组件
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
285
components/detail/ThreeDOriginalDetail.tsx
Normal file
285
components/detail/ThreeDOriginalDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
759
components/upload/AIOriginalUploader-backup.tsx
Normal file
759
components/upload/AIOriginalUploader-backup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
533
components/upload/AIOriginalUploader.tsx
Normal file
533
components/upload/AIOriginalUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
530
components/upload/RealPhotoUploader.tsx
Normal file
530
components/upload/RealPhotoUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
533
components/upload/ThreeDOriginalUploader.tsx
Normal file
533
components/upload/ThreeDOriginalUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
849
components/upload/ThreeDOriginalUploader.tsx.backup
Normal file
849
components/upload/ThreeDOriginalUploader.tsx.backup
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
758
components/upload/temp-backup.txt
Normal file
758
components/upload/temp-backup.txt
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ export const PREVIEW_OPTIONAL_TYPES: string[] = [
|
||||
'详情页',
|
||||
'详情模板',
|
||||
'模特',
|
||||
'实拍图片',
|
||||
'任务-实拍原图',
|
||||
'买家秀图片',
|
||||
'小红书种草',
|
||||
'主图模版',
|
||||
|
||||
239
data-recovery-action-plan.md
Normal file
239
data-recovery-action-plan.md
Normal 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
450
detailed-recovery-plan.md
Normal 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. **根据检查结果决定后续步骤**
|
||||
164
disk-level-recovery-options.md
Normal file
164
disk-level-recovery-options.md
Normal 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快照功能,预防类似问题
|
||||
@ -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
|
||||
|
||||
@ -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
106
docker-data-recovery.sh
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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`(可无限层级)
|
||||
- 唯一性:数据库级别唯一约束
|
||||
|
||||
### 存储策略
|
||||
|
||||
@ -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
173
fix-nginx-configs.sh
Normal 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
107
fix-truenas-nginx-config.sh
Normal 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
130
fix-truenas-nginx-final.sh
Normal 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
305
free-zfs-recovery-tools.md
Normal 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%
|
||||
@ -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
149
hard-disk-recovery-guide.md
Normal 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
199
hard-disk-recovery-plan.md
Normal 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目录创建定期快照
|
||||
@ -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 {
|
||||
|
||||
@ -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
190
install-docker.ps1
Normal 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
|
||||
104
install-recovery-tools-truenas.sh
Normal file
104
install-recovery-tools-truenas.sh
Normal 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
221
iscsi-operation-guide.md
Normal 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
130
iscsi-recovery-analysis.md
Normal 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
198
iscsi-recovery-guide.md
Normal 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/
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user