diff --git a/.dockerignore b/.dockerignore index 324d1d2..a9fd2fb 100644 --- a/.dockerignore +++ b/.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 diff --git a/.kiro/steering/项目记忆库.md b/.kiro/steering/项目记忆库.md index 6a7f0e6..24df9c1 100644 --- a/.kiro/steering/项目记忆库.md +++ b/.kiro/steering/项目记忆库.md @@ -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* diff --git a/Docker安装指南.md b/Docker安装指南.md new file mode 100644 index 0000000..7bb4e14 --- /dev/null +++ b/Docker安装指南.md @@ -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 正常工作!** diff --git a/GUID引导重建指南.md b/GUID引导重建指南.md new file mode 100644 index 0000000..6bdc7ee --- /dev/null +++ b/GUID引导重建指南.md @@ -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. **验证**:确认系统可以正常启动 + +记住:**数据安全第一,操作前务必备份!** diff --git a/TrueNAS关机与硬盘操作安全指南.md b/TrueNAS关机与硬盘操作安全指南.md new file mode 100644 index 0000000..3e9ad0d --- /dev/null +++ b/TrueNAS关机与硬盘操作安全指南.md @@ -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) diff --git a/UFS-Explorer-数据恢复操作指南.md b/UFS-Explorer-数据恢复操作指南.md new file mode 100644 index 0000000..fadedb5 --- /dev/null +++ b/UFS-Explorer-数据恢复操作指南.md @@ -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 diff --git a/UFS-Explorer-界面翻译对照表.md b/UFS-Explorer-界面翻译对照表.md new file mode 100644 index 0000000..a462e37 --- /dev/null +++ b/UFS-Explorer-界面翻译对照表.md @@ -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 diff --git a/USB硬盘盒数据恢复详细计划.md b/USB硬盘盒数据恢复详细计划.md new file mode 100644 index 0000000..a5ab62e --- /dev/null +++ b/USB硬盘盒数据恢复详细计划.md @@ -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系统 diff --git a/app/api/admin/api-keys/[id]/route.ts b/app/api/admin/api-keys/[id]/route.ts index aa2f1bf..6f5e61c 100644 --- a/app/api/admin/api-keys/[id]/route.ts +++ b/app/api/admin/api-keys/[id]/route.ts @@ -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( diff --git a/app/api/admin/api-keys/route.ts b/app/api/admin/api-keys/route.ts index 067ef06..8dd5eca 100644 --- a/app/api/admin/api-keys/route.ts +++ b/app/api/admin/api-keys/route.ts @@ -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, diff --git a/app/api/admin/api-logs/route.ts b/app/api/admin/api-logs/route.ts index e1a7b79..0522b7a 100644 --- a/app/api/admin/api-logs/route.ts +++ b/app/api/admin/api-logs/route.ts @@ -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( diff --git a/app/api/admin/materials/batch-delete/route.ts b/app/api/admin/materials/batch-delete/route.ts index 03ea15b..c7d9196 100644 --- a/app/api/admin/materials/batch-delete/route.ts +++ b/app/api/admin/materials/batch-delete/route.ts @@ -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({ diff --git a/app/api/admin/users/set-role/route.ts b/app/api/admin/users/set-role/route.ts index 08992c4..6782ad5 100644 --- a/app/api/admin/users/set-role/route.ts +++ b/app/api/admin/users/set-role/route.ts @@ -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 } diff --git a/app/api/admin/users/toggle-active/route.ts b/app/api/admin/users/toggle-active/route.ts index 27dd7b0..9128d52 100644 --- a/app/api/admin/users/toggle-active/route.ts +++ b/app/api/admin/users/toggle-active/route.ts @@ -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 } diff --git a/app/api/admin/users/toggle-download/route.ts b/app/api/admin/users/toggle-download/route.ts index 4e6edd4..e08b8ef 100644 --- a/app/api/admin/users/toggle-download/route.ts +++ b/app/api/admin/users/toggle-download/route.ts @@ -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 } diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index e5d34b9..417096f 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -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 }); } diff --git a/app/api/danmaku/route.ts b/app/api/danmaku/route.ts index 81e0006..208393b 100644 --- a/app/api/danmaku/route.ts +++ b/app/api/danmaku/route.ts @@ -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, }, }); diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 3879130..02b6d94 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -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) { diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index 7413124..b18656f 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -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); } } diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index e0930ac..277a2e4 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -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 } diff --git a/app/api/materials/[id]/assets/sort/route.ts b/app/api/materials/[id]/assets/sort/route.ts index a790e57..d6cb0c7 100644 --- a/app/api/materials/[id]/assets/sort/route.ts +++ b/app/api/materials/[id]/assets/sort/route.ts @@ -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 } diff --git a/app/api/materials/[id]/retry-transcode/route.ts b/app/api/materials/[id]/retry-transcode/route.ts index 468a7da..158c703 100644 --- a/app/api/materials/[id]/retry-transcode/route.ts +++ b/app/api/materials/[id]/retry-transcode/route.ts @@ -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 }) } diff --git a/app/api/materials/[id]/skus/[skuId]/download/route.ts b/app/api/materials/[id]/skus/[skuId]/download/route.ts index 87920ee..b3ad6b3 100644 --- a/app/api/materials/[id]/skus/[skuId]/download/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/download/route.ts @@ -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 } }) diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts index cb73347..97947d3 100644 --- a/app/api/materials/[id]/skus/[skuId]/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -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 }) } diff --git a/app/api/materials/[id]/skus/reorder/route.ts b/app/api/materials/[id]/skus/reorder/route.ts index 01fe4e7..7f47d07 100644 --- a/app/api/materials/[id]/skus/reorder/route.ts +++ b/app/api/materials/[id]/skus/reorder/route.ts @@ -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 }) } diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts index 4bcb0c6..1f06eb7 100644 --- a/app/api/materials/[id]/skus/route.ts +++ b/app/api/materials/[id]/skus/route.ts @@ -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 }) } diff --git a/app/api/materials/[id]/skus/upload/route.ts b/app/api/materials/[id]/skus/upload/route.ts index 7764fa2..fbd0e14 100644 --- a/app/api/materials/[id]/skus/upload/route.ts +++ b/app/api/materials/[id]/skus/upload/route.ts @@ -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 }) } diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 3655088..8474c5f 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -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; diff --git a/app/api/materials/favorite/route.ts b/app/api/materials/favorite/route.ts index 6d08bb7..1065e46 100644 --- a/app/api/materials/favorite/route.ts +++ b/app/api/materials/favorite/route.ts @@ -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({ diff --git a/app/api/materials/like/route.ts b/app/api/materials/like/route.ts index 538d28a..5a2198e 100644 --- a/app/api/materials/like/route.ts +++ b/app/api/materials/like/route.ts @@ -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({ diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts index 8148d06..440edd6 100644 --- a/app/api/materials/restore/route.ts +++ b/app/api/materials/restore/route.ts @@ -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 }); } diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 427d03b..0972e6c 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -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(); const favoritedSet = new Set(); - 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, }); // 记录日志 diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 0dcfb14..0788143 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -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:按文件大小阈值) diff --git a/app/api/materials/upload/3d-original/complete/route.ts b/app/api/materials/upload/3d-original/complete/route.ts new file mode 100644 index 0000000..6dbc86a --- /dev/null +++ b/app/api/materials/upload/3d-original/complete/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/materials/upload/3d-original/init/route.ts b/app/api/materials/upload/3d-original/init/route.ts new file mode 100644 index 0000000..9cb813b --- /dev/null +++ b/app/api/materials/upload/3d-original/init/route.ts @@ -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 } + ); + } +} diff --git a/app/api/materials/upload/ai-original/complete/route.ts b/app/api/materials/upload/ai-original/complete/route.ts new file mode 100644 index 0000000..b2ff9b4 --- /dev/null +++ b/app/api/materials/upload/ai-original/complete/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/materials/upload/ai-original/init/route.ts b/app/api/materials/upload/ai-original/init/route.ts new file mode 100644 index 0000000..31d7a6d --- /dev/null +++ b/app/api/materials/upload/ai-original/init/route.ts @@ -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 } + ); + } +} diff --git a/app/api/materials/upload/cleanup/route.ts b/app/api/materials/upload/cleanup/route.ts index 08b0d4d..00ba7db 100644 --- a/app/api/materials/upload/cleanup/route.ts +++ b/app/api/materials/upload/cleanup/route.ts @@ -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 }) // } diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index 4c81889..4255f1e 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -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 } ); } diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index c0bd7a5..5f49f20 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -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, }); diff --git a/app/api/materials/upload/real-photo/complete/route.ts b/app/api/materials/upload/real-photo/complete/route.ts new file mode 100644 index 0000000..ac37df2 --- /dev/null +++ b/app/api/materials/upload/real-photo/complete/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/app/api/materials/upload/real-photo/init/route.ts b/app/api/materials/upload/real-photo/init/route.ts new file mode 100644 index 0000000..693a120 --- /dev/null +++ b/app/api/materials/upload/real-photo/init/route.ts @@ -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 } + ); + } +} diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index ebd4833..05ac493 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -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: { diff --git a/app/api/performance/quick/route.ts b/app/api/performance/quick/route.ts index a33d71d..bfe7577 100644 --- a/app/api/performance/quick/route.ts +++ b/app/api/performance/quick/route.ts @@ -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) diff --git a/app/api/platforms/route.ts b/app/api/platforms/route.ts index a04a3f4..bb25964 100644 --- a/app/api/platforms/route.ts +++ b/app/api/platforms/route.ts @@ -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 } diff --git a/app/api/product-categories/route.ts b/app/api/product-categories/route.ts index 23f4351..eed1545 100644 --- a/app/api/product-categories/route.ts +++ b/app/api/product-categories/route.ts @@ -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 }); } diff --git a/app/api/scenes/route.ts b/app/api/scenes/route.ts index 164765b..7e54ef0 100644 --- a/app/api/scenes/route.ts +++ b/app/api/scenes/route.ts @@ -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 }); } diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 5079559..110a90b 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -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 } diff --git a/app/api/system-settings/upload-logo/route.ts b/app/api/system-settings/upload-logo/route.ts index 4c19e30..e8b5a55 100644 --- a/app/api/system-settings/upload-logo/route.ts +++ b/app/api/system-settings/upload-logo/route.ts @@ -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 } diff --git a/app/api/test-download/route.ts b/app/api/test-download/route.ts new file mode 100644 index 0000000..fa9dde1 --- /dev/null +++ b/app/api/test-download/route.ts @@ -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 } + ); + } +} diff --git a/app/api/upload/s3-original/route.ts b/app/api/upload/s3-original/route.ts new file mode 100644 index 0000000..3a2df59 --- /dev/null +++ b/app/api/upload/s3-original/route.ts @@ -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 } + ); + } +} diff --git a/app/api/user/avatar/route.ts b/app/api/user/avatar/route.ts index 051ec1d..d2bb96d 100644 --- a/app/api/user/avatar/route.ts +++ b/app/api/user/avatar/route.ts @@ -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 }); diff --git a/app/api/user/favorites/route.ts b/app/api/user/favorites/route.ts index b4dff38..5517dfe 100644 --- a/app/api/user/favorites/route.ts +++ b/app/api/user/favorites/route.ts @@ -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({ diff --git a/app/api/user/materials/route.ts b/app/api/user/materials/route.ts index 8aa1c0c..30ec61e 100644 --- a/app/api/user/materials/route.ts +++ b/app/api/user/materials/route.ts @@ -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: { diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts index bd0f308..659e09f 100644 --- a/app/api/user/profile/route.ts +++ b/app/api/user/profile/route.ts @@ -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 }); diff --git a/app/page.tsx b/app/page.tsx index 807fa60..968056c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -73,26 +73,35 @@ export default function Home() { const [platforms, setPlatforms] = useState>([]) const [productCategories, setProductCategories] = useState>([]) 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) diff --git a/app/register/page.tsx b/app/register/page.tsx index d7a60a2..6796e7f 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -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) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index f1eb240..de9c67d 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -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() {
{/* 产品全案类型:使用专用详情组件 */} - {material.materialType === '产品全案' ? ( + {material.materialType === '任务-详情' ? (
router.back()} />
+ ) : material.materialType === '任务-3D原图' || material.materialType === '任务-AI原图' || material.materialType === '任务-实拍原图' ? ( + /* 3D原图类型:使用专用详情组件 */ +
+ { + // 处理下载,使用与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()} + /> +
) : ( /* 其他素材类型:使用原有左右布局 */ <> diff --git a/check-truenas-iscsi.sh b/check-truenas-iscsi.sh new file mode 100644 index 0000000..86d6eed --- /dev/null +++ b/check-truenas-iscsi.sh @@ -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 "" diff --git a/check-zfs-snapshots.sh b/check-zfs-snapshots.sh new file mode 100644 index 0000000..e305adf --- /dev/null +++ b/check-zfs-snapshots.sh @@ -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 "==========================================" diff --git a/complete-recovery-execution-plan.md b/complete-recovery-execution-plan.md new file mode 100644 index 0000000..8e1d25f --- /dev/null +++ b/complete-recovery-execution-plan.md @@ -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. **专业帮助**:如果遇到困难,考虑专业数据恢复服务 diff --git a/components/UpdateLogModal.tsx b/components/UpdateLogModal.tsx index c7b3f6b..120456d 100644 --- a/components/UpdateLogModal.tsx +++ b/components/UpdateLogModal.tsx @@ -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', diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index 531aab5..ab55be5 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -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相关 */} + + + + {/* 图片相关 */} - - - - + {/* 视频相关 */} + {/* 模板和种草 */} + + + + {/* 其他 */} - - - - - + {/* 任务相关 */} + + + + - {/* 任务号输入框(产品全案不显示,因为产品全案有自己的任务号输入) */} - {materialType !== '产品全案' && ( + {/* 任务号输入框(任务-详情/任务-3D原图/任务-AI原图/任务-实拍原图不显示,因为有自己的任务号输入) */} + {materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
)} - {/* 产品全案上传组件 */} - {materialType === '产品全案' && ( + {/* 任务-详情上传组件 */} + {materialType === '任务-详情' && (
{ @@ -1521,8 +1536,65 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
)} + {/* 任务-3D原图上传组件 */} + {materialType === '任务-3D原图' && ( +
+ { + onSuccess(); + onClose(); + }} + onCancel={onClose} + categories={categories} + scenes={scenes} + platforms={platforms} + productCategories={productCategories} + onRefreshCategories={fetchCategories} + onRefreshScenes={fetchScenes} + /> +
+ )} + + {/* 任务-AI原图上传组件 */} + {materialType === '任务-AI原图' && ( +
+ { + onSuccess(); + onClose(); + }} + onCancel={onClose} + categories={categories} + scenes={scenes} + platforms={platforms} + productCategories={productCategories} + onRefreshCategories={fetchCategories} + onRefreshScenes={fetchScenes} + /> +
+ )} + + {/* 任务-实拍原图上传组件 */} + {materialType === '任务-实拍原图' && ( +
+ { + onSuccess(); + onClose(); + }} + onCancel={onClose} + categories={categories} + scenes={scenes} + platforms={platforms} + productCategories={productCategories} + onRefreshCategories={fetchCategories} + onRefreshScenes={fetchScenes} + /> +
+ )} + {/* 批量上传模式:可折叠文件列表 */} - {materialType !== '产品全案' && isBatchMode && batchFiles.length > 0 && ( + {materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && isBatchMode && batchFiles.length > 0 && ( )} - {/* 预览图片(产品全案不显示,批量模式下隐藏) */} - {materialType !== '产品全案' && !isBatchMode && ( + {/* 预览图片(任务类型不显示,批量模式下隐藏) */} + {materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && !isBatchMode && ( )} - {/* 表单字段 - 使用 UploadForm 组件(产品全案不显示) */} - {materialType !== '产品全案' && ( + {/* 表单字段 - 使用 UploadForm 组件(任务类型不显示) */} + {materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && ( { 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 !== '任务-实拍原图' && (
)} - {/* Buttons(产品全案不显示,因为有自己的按钮) */} - {materialType !== '产品全案' && ( + {/* Buttons(任务类型不显示,因为有自己的按钮) */} + {materialType !== '任务-详情' && materialType !== '任务-3D原图' && materialType !== '任务-AI原图' && materialType !== '任务-实拍原图' && (
+ )} +
+
+ {`原图 +
+ + )} + + {/* 原图小卡片展示区 */} + {mainImages.length > 0 && ( +
+

所有原图 ({mainImages.length})

+
+ {mainImages.map((img, index) => ( + + ))} +
+
+ )} + + + {/* 右侧:基本信息 + 下载按钮 */} +
+ {/* 基本信息卡片 */} +
+

素材详情

+
+
+

任务号

+

{material.taskNo}

+
+
+

SPU型号

+

{material.productModel}

+
+
+

店铺

+

{material.categoryName}

+
+ {material.sceneName && ( +
+

场景

+

{material.sceneName}

+
+ )} +
+
+ + {/* ZIP 文件信息和下载按钮 - 使用容器实现sticky,保持相对位置 */} +
+ {/* ZIP 源文件 */} + {zipFile && ( +
+

源文件

+
+
+

{zipFile.fileName}

+

{formatBytes(zipFile.fileSize)}

+
+ {canDownloadSource ? ( + + ) : ( + 无权限 + )} +
+
+ )} + + {/* 返回上一页按钮 */} + {onBack && ( + + )} +
+
+ + + ); +} diff --git a/components/upload/AIOriginalUploader-backup.tsx b/components/upload/AIOriginalUploader-backup.tsx new file mode 100644 index 0000000..2da3e5e --- /dev/null +++ b/components/upload/AIOriginalUploader-backup.tsx @@ -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([]); + const [mainImages, setMainImages] = useState([]); // 原图列表,后端会自动压缩为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(null); + const mainInputRef = useRef(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 => { + 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> => { + 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 ( +
+ {/* 任务号字段 */} +
+ + 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) && ( +

任务号格式错误

+ )} +
+ + {/* SPU型号 */} +
+ + { + 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} + /> +
+ + {/* 品类分类 */} + {productCategories.length > 0 && ( +
+ + +
+ )} + + {/* 使用店铺 */} +
+ + {showNewCategoryInput ? ( +
+ 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} + /> + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 场景类别 */} +
+ + {showNewSceneInput ? ( +
+ 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} + /> + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 平台分类 */} + {platforms.length > 0 && ( +
+ + +
+ )} + + {/* 预览图上传区 */} +
+ +
handleDragOver(e, 'preview')} + onDragLeave={(e) => handleDragLeave(e, 'preview')} + onDrop={(e) => handleDrop(e, 'preview')} + > + handleImageSelect(e.target.files)} + className="hidden" + disabled={uploading} + /> + +

+ 或拖拽图片到此区域 +

+
+ + {/* 预览图列表 */} + {previewImages.length > 0 && ( +
+ {previewImages.map((img, index) => ( +
+ {`预览图 + +
+ {formatBytes(img.file.size)} +
+
+ ))} +
+ )} +
+ + {/* 原图上传区 */} +
+ +
handleDragOver(e, 'main')} + onDragLeave={(e) => handleDragLeave(e, 'main')} + onDrop={(e) => handleDrop(e, 'main')} + > + handleMainImageSelect(e.target.files)} + className="hidden" + disabled={uploading} + /> + +

+ 或拖拽图片到此区域(可多选) +

+
+ + {/* 原图列表 */} + {mainImages.length > 0 && ( +
+ {mainImages.map((img, index) => ( +
+ {`原图 + +
+ {formatBytes(img.file.size)} +
+
+ ))} +
+ )} +
+ + {/* 错误提示 */} + {error && ( +
+

{error}

+
+ )} + + {/* 上传进度 */} + {uploading && ( +
+
+ 上传中... + {uploadProgress}% +
+
+
+
+
+ )} + + {/* 操作按钮 */} +
+ + +
+
+ ); +} diff --git a/components/upload/AIOriginalUploader.tsx b/components/upload/AIOriginalUploader.tsx new file mode 100644 index 0000000..2b591d1 --- /dev/null +++ b/components/upload/AIOriginalUploader.tsx @@ -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([]); + const [isMainDragging, setIsMainDragging] = useState(false); + + // 上传状态 + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(''); + + // 文件输入引用 + const mainInputRef = useRef(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 ( +
+ {/* 任务号字段 */} +
+ + 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) && ( +

任务号格式错误

+ )} +
+ + {/* SPU型号 */} +
+ + { + 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} + /> +
+ + {/* 品类分类 */} + {productCategories.length > 0 && ( +
+ + +
+ )} + + {/* 使用店铺 */} +
+ + {showNewCategoryInput ? ( +
+ 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} + /> + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 场景类别 */} +
+ + {showNewSceneInput ? ( +
+ 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} + /> + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* 平台分类 */} + {platforms.length > 0 && ( +
+ + +
+ )} + + {/* 原图上传区 */} +
+ +
+ handleMainImageSelect(e.target.files)} + className="hidden" + disabled={uploading} + /> + +

+ 或拖拽图片到此区域(可多选) +

+
+ + {/* 原图列表 */} + {mainImages.length > 0 && ( +
+ {mainImages.map((img, index) => ( +
+ {`原图 + +
+ {formatBytes(img.file.size)} +
+
+ ))} +
+ )} +
+ + {/* 错误提示 */} + {error && ( +
+

{error}

+
+ )} + + {/* 上传进度 */} + {uploading && ( +
+
+ 上传中... + {uploadProgress}% +
+
+
+
+
+ )} + + {/* 操作按钮 */} +
+ + +
+
+ ); +} diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index eb89f9d..7ce50b8 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -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([]); @@ -67,10 +69,13 @@ export default function ProductPackageUploader({ const psdInputRef = useRef(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 (
- {/* 任务号字段(产品全案独有) */} + {/* 任务号字段(任务-详情独有) */}