diff --git a/.kiro/specs/product-package/design.md b/.kiro/specs/product-package/design.md new file mode 100644 index 0000000..e843481 --- /dev/null +++ b/.kiro/specs/product-package/design.md @@ -0,0 +1,417 @@ +# 设计文档:产品全案素材类型 + +## 概述 + +本文档描述「产品全案」素材类型的技术实现方案,包括数据库设计、API 设计、前端组件设计等。 + +--- + +## 1. 数据库设计 + +### 1.1 新增表 + +#### MaterialAsset(素材资产表) + +存储产品全案中的各类资产(主图/SKU图/详情图/PSD)。 + +```prisma +model MaterialAsset { + id String @id @default(uuid()) + materialId String @map("material_id") + assetType String @map("asset_type") // main/sku/detail/psd + fileUrl String @map("file_url") // S3 URL 或本地路径 + fileName String? @map("file_name") // 原始文件名 + fileSize BigInt @default(0) @map("file_size") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + material Material @relation(fields: [materialId], references: [id], onDelete: Cascade) + + @@index([materialId]) + @@index([assetType]) + @@index([sortOrder]) + @@map("material_assets") +} +``` + +#### ApiKey(API 密钥表) + +管理外部接口访问密钥。 + +```prisma +model ApiKey { + id String @id @default(uuid()) + name String // 密钥名称(如:任务系统) + key String @unique // 密钥值(32位随机字符串) + isActive Boolean @default(true) // 是否启用 + createdBy String // 创建人ID + lastUsedAt DateTime? // 最后使用时间 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + callLogs ApiCallLog[] + + @@index([key]) + @@index([isActive]) + @@map("api_keys") +} +``` + +#### ApiCallLog(API 调用日志表) + +记录外部接口调用日志。 + +```prisma +model ApiCallLog { + id String @id @default(uuid()) + apiKeyId String @map("api_key_id") + endpoint String // 调用的接口路径 + method String // HTTP 方法 + taskNo String? @map("task_no") // 任务号(如有) + statusCode Int @map("status_code") // 响应状态码 + duration Int // 耗时(毫秒) + ipAddress String? @map("ip_address") // 调用方IP + userAgent String? @map("user_agent") // User-Agent + createdAt DateTime @default(now()) @map("created_at") + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + @@index([apiKeyId]) + @@index([taskNo]) + @@index([createdAt]) + @@map("api_call_logs") +} +``` + +### 1.2 修改现有表 + +#### Material 表新增字段 + +```prisma +// 在 Material 模型中新增 +taskNo String? @unique @map("task_no") // 任务号(唯一) +taskStatus String? @map("task_status") // uploading/completed/fetched +taskFetchedAt DateTime? @map("task_fetched_at") // 外部系统获取时间 +``` + +#### DepartmentMaterialPermission 表新增字段 + +```prisma +// 在 DepartmentMaterialPermission 模型中新增 +canDownloadSource Boolean @default(false) @map("can_download_source") // 是否可下载源文件(PSD) +``` + +### 1.3 关系图 + +``` +Material (产品全案) + │ + ├── taskNo (任务号,唯一) + ├── taskStatus (uploading/completed/fetched) + ├── taskFetchedAt (获取时间) + │ + └── MaterialAsset[] (资产列表) + ├── assetType: main (主图) + ├── assetType: sku (SKU图) + ├── assetType: detail (详情图) + └── assetType: psd (PSD源文件) +``` + +--- + +## 2. 存储策略 + +### 2.1 文件存储位置 + +| 资产类型 | 存储位置 | 路径格式 | +|---------|---------|---------| +| 主图 | S3 (MinIO) | `由壹点AI/product-package/{materialId}/main/{filename}` | +| SKU图 | S3 (MinIO) | `由壹点AI/product-package/{materialId}/sku/{filename}` | +| 详情图 | S3 (MinIO) | `由壹点AI/product-package/{materialId}/detail/{filename}` | +| PSD源文件 | 本地 uploads | `/uploads/product-package/{materialId}/psd/{filename}` | + +### 2.2 文件命名规则 + +- 主图:`main-{timestamp}-{index}.{ext}` +- SKU图:`sku-{timestamp}-{index}.{ext}` +- 详情图:`detail-{timestamp}-{sortOrder}.{ext}` +- PSD:`psd-{timestamp}-{originalName}` + +--- + +## 3. API 设计 + +### 3.1 内部 API + +#### 上传初始化 +``` +POST /api/materials/upload/product-package/init +Body: { + taskNo: string, // 任务号(必填) + productModel: string, // SPU型号(必填) + categoryName: string, // 店铺(必填) + sceneName?: string, // 场景(可选) +} +Response: { + materialId: string, + uploadUrls: { + main: string[], + sku: string[], + detail: string[], + psd: string + } +} +``` + +#### 上传完成 +``` +POST /api/materials/upload/product-package/complete +Body: { + materialId: string, + assets: { + main: Array<{ fileName, fileSize, fileUrl }>, + sku: Array<{ fileName, fileSize, fileUrl }>, + detail: Array<{ fileName, fileSize, fileUrl, sortOrder }>, + psd?: { fileName, fileSize, filePath } + } +} +``` + +#### 更新排序 +``` +PUT /api/materials/{id}/assets/sort +Body: { + assetType: 'detail', + orders: Array<{ assetId: string, sortOrder: number }> +} +``` + +#### 下载打包 +``` +GET /api/materials/{id}/download?type=all|main|sku|detail|psd +``` + +### 3.2 外部 API + +#### 查询素材详情 +``` +GET /api/external/product-assets?taskNo={taskNo} +Headers: { + X-API-Key: {apiKey} +} +Response: { + success: true, + data: { + taskNo: string, + productModel: string, + categoryName: string, + sceneName: string, + status: string, + assets: { + main: Array<{ url, downloadUrl, fileName, fileSize }>, + sku: Array<{ url, downloadUrl, fileName, fileSize }>, + detail: Array<{ url, downloadUrl, fileName, fileSize, sortOrder }>, + psd: { downloadUrl, fileName, fileSize } | null + }, + downloadUrls: { + all: string, // 全部打包下载链接(不含PSD) + main: string, // 主图打包下载链接 + sku: string, // SKU图打包下载链接 + detail: string, // 详情图打包下载链接 + psd: string | null // PSD下载链接 + } + } +} +``` + +#### 下载文件 +``` +GET /api/external/download?token={downloadToken} +Response: 文件流 +``` + +#### 删除素材 +``` +DELETE /api/external/product-assets?taskNo={taskNo} +Headers: { + X-API-Key: {apiKey} +} +Response: { + success: true, + message: '素材已删除' +} +``` + +### 3.3 下载 Token 设计 + +```typescript +interface DownloadToken { + materialId: string; + assetType: 'all' | 'main' | 'sku' | 'detail' | 'psd'; + assetId?: string; // 单个资产下载时使用 + exp: number; // 过期时间戳(24小时后) +} + +// Token 生成 +const token = jwt.sign(payload, process.env.DOWNLOAD_TOKEN_SECRET, { expiresIn: '24h' }); + +// Token 验证 +const decoded = jwt.verify(token, process.env.DOWNLOAD_TOKEN_SECRET); +``` + +--- + +## 4. 前端组件设计 + +### 4.1 上传组件 + +``` +ProductPackageUploadForm +├── TaskNoInput (任务号输入,带格式校验) +├── BasicInfoForm (SPU、店铺、场景) +├── MainImageUploader (主图上传区) +├── SkuImageUploader (SKU图上传区) +├── DetailImageUploader (详情图上传区 + 排序) +│ ├── DetailPreview (左侧实时预览) +│ └── DetailThumbnails (右侧可拖拽缩略图) +└── PsdUploader (PSD上传区,可选) +``` + +### 4.2 详情页组件 + +``` +ProductPackageDetail +├── LeftPanel +│ ├── MainImageCarousel (主图轮播) +│ └── DetailImageScroll (详情图滚动区) +├── RightPanel +│ ├── BasicInfo (基本信息) +│ ├── SkuImageGrid (SKU图网格) +│ └── DownloadButtons (下载按钮组) +│ ├── DownloadAllButton +│ ├── DownloadMainButton +│ ├── DownloadSkuButton +│ ├── DownloadDetailButton +│ └── DownloadPsdButton (权限控制) +└── EditWarning (已被获取警告,条件显示) +``` + +### 4.3 详情图排序交互 + +``` +DetailImageSorter +├── PreviewArea (左侧) +│ └── ConcatenatedPreview (按顺序拼接的详情图) +├── ThumbnailList (右侧) +│ └── DraggableThumbnail[] (可拖拽缩略图) +└── 交互逻辑: + - 拖拽缩略图 → 实时更新预览区 + - 点击缩略图 → 预览区滚动到对应位置 +``` + +--- + +## 5. 权限设计 + +### 5.1 PSD 下载权限检查流程 + +```typescript +async function canDownloadPsd(userId: string, materialType: string): Promise { + // 1. 超级管理员直接允许 + if (await isSuperAdmin(userId)) return true; + + // 2. 获取用户部门 + const user = await getUser(userId); + const department = user.departmentRef?.name || user.department; + if (!department) return false; + + // 3. 检查部门权限 + const permission = await getDepartmentPermission(department, materialType); + return permission?.canDownloadSource ?? false; +} +``` + +### 5.2 权限配置界面 + +在管理后台的「部门权限配置」页面,为「产品全案」类型新增「可下载源文件」选项。 + +--- + +## 6. 任务状态流转 + +``` +[上传中] uploading + │ + ▼ (上传完成) +[已完成] completed + │ + ▼ (外部系统首次查询) +[已获取] fetched + │ + ▼ (外部系统删除) +[已删除] (deletedAt 不为空) +``` + +--- + +## 7. 错误码设计 + +| 错误码 | 说明 | +|-------|------| +| TASK_NO_EXISTS | 任务号已存在 | +| TASK_NOT_FOUND | 任务号不存在 | +| INVALID_API_KEY | API Key 无效 | +| TOKEN_EXPIRED | 下载 Token 已过期 | +| INVALID_TOKEN | 下载 Token 无效 | +| MATERIAL_DELETED | 素材已被删除 | +| NO_PERMISSION | 无权限 | +| NO_SOURCE_PERMISSION | 无源文件下载权限 | + +--- + +## 8. 复用现有逻辑 + +### 8.1 可复用的模块 + +| 模块 | 复用方式 | +|-----|---------| +| S3 上传 | 复用 `lib/s3.ts` 的上传逻辑 | +| 分片上传 | 复用 `hooks/useBatchFilesUpload.ts` | +| 权限检查 | 扩展 `lib/department-permission.ts` | +| 软删除 | 复用现有 `deletedAt` 逻辑 | +| 下载打包 | 参考 `app/api/materials/download/route.ts` | + +### 8.2 需要新建的模块 + +| 模块 | 说明 | +|-----|------| +| `lib/product-package.ts` | 产品全案业务逻辑 | +| `lib/external-api.ts` | 外部接口鉴权和日志 | +| `lib/download-token.ts` | 下载 Token 生成和验证 | +| `components/upload/ProductPackageUploader.tsx` | 上传组件 | +| `components/detail/ProductPackageDetail.tsx` | 详情组件 | + +--- + +## 9. 环境变量 + +```env +# 下载 Token 密钥 +DOWNLOAD_TOKEN_SECRET=your-secret-key-here +``` + +--- + +## 10. 数据库迁移步骤 + +1. 添加 Material 表新字段(taskNo, taskStatus, taskFetchedAt) +2. 添加 DepartmentMaterialPermission 表新字段(canDownloadSource) +3. 创建 MaterialAsset 表 +4. 创建 ApiKey 表 +5. 创建 ApiCallLog 表 +6. 为「产品全案」类型创建默认权限配置 + +--- + +*更新: 2026-01-03* diff --git a/.kiro/specs/product-package/requirements.md b/.kiro/specs/product-package/requirements.md new file mode 100644 index 0000000..18858ea --- /dev/null +++ b/.kiro/specs/product-package/requirements.md @@ -0,0 +1,160 @@ +# 需求文档:产品全案素材类型 + +## 简介 + +新增「产品全案」素材类型,用于整合一个产品的完整电商视觉资产(主图、SKU图、详情预览图、PSD源文件),并提供外部接口供任务系统对接。 + +## 术语表 + +- **Product_Package_System**: 产品全案素材管理系统 +- **Material_Asset**: 素材资产(主图/SKU图/详情图/PSD) +- **External_API**: 外部接口服务 +- **Task_System**: 外部任务系统 +- **API_Key**: 接口访问密钥 +- **Download_Token**: 下载令牌 + +--- + +## 需求 + +### 需求 1:素材上传 + +**用户故事:** 作为设计师,我想要上传产品全案素材,以便将一个产品的所有视觉资产整合在一起。 + +#### 验收标准 + +1. WHEN 用户选择「产品全案」素材类型 THEN THE Product_Package_System SHALL 显示任务号、SPU型号、店铺(必填)、场景(可选)输入字段 +2. WHEN 用户上传主图 THEN THE Product_Package_System SHALL 接受多张图片且不做压缩处理,保持原图质量 +3. WHEN 用户上传SKU图 THEN THE Product_Package_System SHALL 接受多张图片且不做压缩处理 +4. WHEN 用户上传详情预览图 THEN THE Product_Package_System SHALL 支持批量拖拽上传已切片的图片 +5. WHEN 用户上传PSD源文件 THEN THE Product_Package_System SHALL 接受 .psd/.zip/.rar/.7z 格式文件并存储到本地 uploads 目录 +6. WHEN 用户未上传PSD源文件 THEN THE Product_Package_System SHALL 允许提交(PSD为可选) +7. WHEN 用户输入已存在的任务号 THEN THE Product_Package_System SHALL 返回错误提示「任务号已存在」 +8. WHEN 上传成功 THEN THE Product_Package_System SHALL 将任务状态设置为 completed + +### 需求 2:详情图排序 + +**用户故事:** 作为设计师,我想要调整详情预览图的顺序,以便控制最终展示效果。 + +#### 验收标准 + +1. WHEN 用户上传多张详情预览图 THEN THE Product_Package_System SHALL 显示可拖拽排序的缩略图列表 +2. WHEN 用户拖拽调整顺序 THEN THE Product_Package_System SHALL 在左侧实时预览区显示按当前顺序拼接的详情图 +3. WHEN 用户点击某张缩略图 THEN THE Product_Package_System SHALL 在预览区滚动到对应位置并高亮显示 +4. WHEN 用户保存排序 THEN THE Product_Package_System SHALL 按 sortOrder 字段存储顺序 + +### 需求 3:素材详情展示 + +**用户故事:** 作为用户,我想要查看产品全案的详情,以便预览和下载所需资源。 + +#### 验收标准 + +1. WHEN 用户打开产品全案详情页 THEN THE Product_Package_System SHALL 在左侧显示主图轮播区和详情预览图滚动区 +2. WHEN 用户查看主图区域 THEN THE Product_Package_System SHALL 显示大图轮播和底部缩略图切换 +3. WHEN 用户查看右侧 THEN THE Product_Package_System SHALL 显示SKU图网格和下载按钮组 +4. WHEN 素材包含PSD文件 THEN THE Product_Package_System SHALL 显示「下载PSD源文件」按钮 +5. WHEN 素材不包含PSD文件 THEN THE Product_Package_System SHALL 隐藏PSD下载按钮 + +### 需求 4:下载功能 + +**用户故事:** 作为用户,我想要按类型下载素材,以便获取所需的资源文件。 + +#### 验收标准 + +1. WHEN 用户点击「下载全部」 THEN THE Product_Package_System SHALL 打包所有主图、SKU图、详情图为ZIP下载(不含PSD) +2. WHEN 用户点击「下载主图」 THEN THE Product_Package_System SHALL 打包所有主图为ZIP下载 +3. WHEN 用户点击「下载SKU图」 THEN THE Product_Package_System SHALL 打包所有SKU图为ZIP下载 +4. WHEN 用户点击「下载详情图」 THEN THE Product_Package_System SHALL 打包所有详情预览图为ZIP下载 +5. WHEN 用户点击「下载PSD源文件」且有权限 THEN THE Product_Package_System SHALL 直接下载PSD文件 +6. WHEN 用户点击「下载PSD源文件」且无权限 THEN THE Product_Package_System SHALL 显示「无权限下载源文件」提示 + +### 需求 5:PSD下载权限控制 + +**用户故事:** 作为管理员,我想要控制哪些部门可以下载PSD源文件,以便保护设计资产。 + +#### 验收标准 + +1. WHEN 管理员配置部门权限 THEN THE Product_Package_System SHALL 显示「可下载源文件」选项(独立于「可下载」权限) +2. WHEN 部门的 canDownloadSource 为 true THEN THE Product_Package_System SHALL 允许该部门用户下载PSD文件 +3. WHEN 部门的 canDownloadSource 为 false THEN THE Product_Package_System SHALL 禁止该部门用户下载PSD文件 +4. WHEN 用户是超级管理员 THEN THE Product_Package_System SHALL 允许下载所有PSD文件 + +### 需求 6:素材编辑 + +**用户故事:** 作为设计师,我想要编辑已上传的产品全案,以便更新或补充资源。 + +#### 验收标准 + +1. WHEN 用户编辑产品全案 THEN THE Product_Package_System SHALL 允许增删改主图、SKU图、详情图 +2. WHEN 用户编辑产品全案 THEN THE Product_Package_System SHALL 允许替换或删除PSD源文件 +3. WHEN 用户编辑产品全案 THEN THE Product_Package_System SHALL 允许调整图片排序 +4. WHEN 素材已被外部系统获取(taskStatus=fetched) THEN THE Product_Package_System SHALL 显示警告「该素材已被外部系统获取,修改后可能影响对方数据」 +5. WHEN 用户确认编辑 THEN THE Product_Package_System SHALL 保存更改并更新 updatedAt + +### 需求 7:外部查询接口 + +**用户故事:** 作为外部任务系统,我想要通过任务号查询素材详情,以便获取下载链接。 + +#### 验收标准 + +1. WHEN 外部系统调用 GET /api/external/product-assets 且 API Key 有效 THEN THE External_API SHALL 返回素材详情和下载链接 +2. WHEN 外部系统提供有效任务号 THEN THE External_API SHALL 返回主图、SKU图、详情图、PSD的图片列表和下载链接 +3. WHEN 外部系统提供无效任务号 THEN THE External_API SHALL 返回 TASK_NOT_FOUND 错误 +4. WHEN API Key 无效或缺失 THEN THE External_API SHALL 返回 INVALID_API_KEY 错误 +5. WHEN 查询成功 THEN THE External_API SHALL 将任务状态更新为 fetched 并记录 taskFetchedAt +6. WHEN 返回下载链接 THEN THE External_API SHALL 生成带 token 的下载链接(有效期24小时) + +### 需求 8:外部下载接口 + +**用户故事:** 作为外部任务系统,我想要通过下载链接获取文件,以便同步资源。 + +#### 验收标准 + +1. WHEN 外部系统调用 GET /api/external/download 且 token 有效 THEN THE External_API SHALL 返回对应文件 +2. WHEN token 已过期 THEN THE External_API SHALL 返回 TOKEN_EXPIRED 错误 +3. WHEN token 无效 THEN THE External_API SHALL 返回 INVALID_TOKEN 错误 +4. WHEN 下载类型为 all THEN THE External_API SHALL 打包主图+SKU图+详情图为ZIP(不含PSD) +5. WHEN 下载类型为 psd THEN THE External_API SHALL 返回PSD源文件 + +### 需求 9:外部删除接口 + +**用户故事:** 作为外部任务系统,我想要删除已完成的任务素材,以便同步删除操作。 + +#### 验收标准 + +1. WHEN 外部系统调用 DELETE /api/external/product-assets 且 API Key 有效 THEN THE External_API SHALL 软删除对应素材 +2. WHEN 删除成功 THEN THE External_API SHALL 返回删除确认信息 +3. WHEN 任务号不存在 THEN THE External_API SHALL 返回 TASK_NOT_FOUND 错误 +4. WHEN 素材已被删除 THEN THE External_API SHALL 返回 MATERIAL_DELETED 错误 + +### 需求 10:API Key 管理 + +**用户故事:** 作为管理员,我想要管理外部接口的访问密钥,以便控制接口访问权限。 + +#### 验收标准 + +1. WHEN 管理员访问 API 管理页面 THEN THE Product_Package_System SHALL 显示 API Key 列表 +2. WHEN 管理员创建 API Key THEN THE Product_Package_System SHALL 生成唯一密钥并显示(仅显示一次) +3. WHEN 管理员禁用 API Key THEN THE Product_Package_System SHALL 将该 Key 标记为无效 +4. WHEN 管理员查看调用日志 THEN THE Product_Package_System SHALL 显示调用时间、任务号、状态码、耗时等信息 +5. WHEN 调用日志超过90天 THEN THE Product_Package_System SHALL 自动清理旧日志 + +### 需求 11:素材卡片展示 + +**用户故事:** 作为用户,我想要在素材列表中识别产品全案类型,以便快速找到所需素材。 + +#### 验收标准 + +1. WHEN 素材列表显示产品全案 THEN THE Product_Package_System SHALL 使用第一张主图作为封面 +2. WHEN 素材列表显示产品全案 THEN THE Product_Package_System SHALL 显示「产品全案」类型角标 +3. IF 素材没有主图 THEN THE Product_Package_System SHALL 使用第一张SKU图作为封面 + +### 需求 12:任务号状态管理 + +**用户故事:** 作为管理员,我想要追踪任务的处理状态,以便了解素材的使用情况。 + +#### 验收标准 + +1. WHEN 素材上传完成 THEN THE Product_Package_System SHALL 将 taskStatus 设置为 completed +2. WHEN 外部系统首次查询素材 THEN THE Product_Package_System SHALL 将 taskStatus 更新为 fetched 并记录时间 +3. WHEN 管理员查看素材详情 THEN THE Product_Package_System SHALL 显示任务状态和获取时间 diff --git a/.kiro/specs/product-package/tasks.md b/.kiro/specs/product-package/tasks.md new file mode 100644 index 0000000..60f22c1 --- /dev/null +++ b/.kiro/specs/product-package/tasks.md @@ -0,0 +1,189 @@ +# 任务清单:产品全案素材类型 + +## 阶段 1:数据库准备 + +- [x] 1.1 修改 `prisma/schema.prisma`,添加 Material 表新字段 + - taskNo (String, unique, optional) + - taskStatus (String, optional) + - taskFetchedAt (DateTime, optional) + +- [x] 1.2 修改 `prisma/schema.prisma`,添加 DepartmentMaterialPermission 表新字段 + - canDownloadSource (Boolean, default false) + +- [x] 1.3 创建 MaterialAsset 模型 + - id, materialId, assetType, fileUrl, fileName, fileSize, sortOrder, createdAt, updatedAt + +- [x] 1.4 创建 ApiKey 模型 + - id, name, key, isActive, createdBy, lastUsedAt, createdAt, updatedAt + +- [x] 1.5 创建 ApiCallLog 模型 + - id, apiKeyId, endpoint, method, taskNo, statusCode, duration, ipAddress, userAgent, createdAt + +- [x] 1.6 执行数据库迁移 + - `npx prisma migrate dev --name add_product_package` + +- [x] 1.7 在 DEFAULT_MATERIAL_TYPES 中添加「产品全案」 + +--- + +## 阶段 2:核心库开发 + +- [x] 2.1 创建 `lib/product-package.ts` + - 任务号格式校验函数 + - 任务号唯一性检查函数 + - 任务状态更新函数 + - 资产管理函数(增删改查) + +- [x] 2.2 创建 `lib/download-token.ts` + - Token 生成函数 + - Token 验证函数 + - Token 过期检查 + +- [x] 2.3 创建 `lib/external-api.ts` + - API Key 验证函数 + - 调用日志记录函数 + - 错误响应生成函数 + +- [x] 2.4 扩展 `lib/department-permission.ts` + - 添加 canDownloadSource 权限检查函数 + +--- + +## 阶段 3:内部 API 开发 + +- [x] 3.1 创建 `app/api/materials/upload/product-package/init/route.ts` + - 任务号校验 + - 创建 Material 记录 + - 返回上传配置 + +- [x] 3.2 创建 `app/api/materials/upload/product-package/complete/route.ts` + - 保存资产信息 + - 更新任务状态为 completed + +- [x] 3.3 创建 `app/api/materials/[id]/assets/route.ts` + - GET: 获取资产列表 + - POST: 添加资产 + - DELETE: 删除资产 + +- [x] 3.4 创建 `app/api/materials/[id]/assets/sort/route.ts` + - PUT: 更新资产排序 + +- [x] 3.5 扩展 `app/api/materials/download/route.ts` + - 支持产品全案分类下载 + - 支持 PSD 权限检查 + +--- + +## 阶段 4:外部 API 开发 + +- [x] 4.1 创建 `app/api/external/product-assets/route.ts` + - GET: 查询素材详情 + - DELETE: 删除素材 + +- [x] 4.2 创建 `app/api/external/download/route.ts` + - GET: Token 验证下载 + +- [x] 4.3 创建 API Key 管理接口 + - `app/api/admin/api-keys/route.ts` (GET, POST) + - `app/api/admin/api-keys/[id]/route.ts` (PUT, DELETE) + +- [x] 4.4 创建 API 调用日志接口 + - `app/api/admin/api-logs/route.ts` (GET) + +--- + +## 阶段 5:前端上传组件 + +- [x] 5.1 创建 `components/upload/ProductPackageUploader.tsx` + - 任务号输入组件 + - 基本信息表单 + - 主图/SKU图/详情图上传 + - PSD 文件上传 + +- [x] 5.2 创建 S3 上传接口 + - `app/api/upload/s3/route.ts` + +- [x] 5.3 创建 PSD 上传接口 + - `app/api/upload/psd/route.ts` + +- [x] 5.4 创建 `components/upload/DetailImageSorter.tsx` + - 左侧实时预览 + - 右侧可拖拽缩略图 + - 拖拽排序逻辑 + +- [x] 5.5 在 UploadModal.tsx 中集成产品全案上传 + +--- + +## 阶段 6:前端详情组件 + +- [ ] 6.1 创建 `components/detail/ProductPackageDetail.tsx` + - 主图轮播区 + - 详情图滚动区 + - SKU图网格 + +- [ ] 6.2 创建 `components/detail/ProductPackageDownloadButtons.tsx` + - 分类下载按钮 + - PSD 权限检查 + +- [ ] 6.3 创建编辑警告组件 + - 已被外部系统获取时显示警告 + +- [ ] 6.4 在素材详情页集成产品全案详情 + +--- + +## 阶段 7:管理后台 + +- [ ] 7.1 创建 API Key 管理页面 + - `app/admin/api-keys/page.tsx` + - 列表、创建、禁用功能 + +- [ ] 7.2 创建 API 调用日志页面 + - `app/admin/api-logs/page.tsx` + - 日志列表、筛选功能 + +- [ ] 7.3 扩展部门权限配置页面 + - 添加「可下载源文件」选项 + +--- + +## 阶段 8:测试和优化 + +- [ ] 8.1 单元测试 + - 任务号格式校验 + - Token 生成和验证 + - API Key 验证 + +- [ ] 8.2 集成测试 + - 完整上传流程 + - 外部 API 调用流程 + - 权限检查流程 + +- [ ] 8.3 性能优化 + - 大文件上传优化 + - 打包下载优化 + +--- + +## 依赖关系 + +``` +阶段 1 (数据库) + ↓ +阶段 2 (核心库) + ↓ +阶段 3 (内部 API) ← 阶段 5 (上传组件) + ↓ +阶段 4 (外部 API) + ↓ +阶段 6 (详情组件) + ↓ +阶段 7 (管理后台) + ↓ +阶段 8 (测试) +``` + +--- + +*更新: 2026-01-03 16:00* diff --git a/app/admin/api-keys/page.tsx b/app/admin/api-keys/page.tsx new file mode 100644 index 0000000..fe4ba4c --- /dev/null +++ b/app/admin/api-keys/page.tsx @@ -0,0 +1,244 @@ +/** + * API Key 管理页面 + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; + +interface ApiKey { + id: string; + name: string; + key: string; + isActive: boolean; + createdBy: string; + lastUsedAt: string | null; + createdAt: string; +} + +export default function ApiKeysPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const [apiKeys, setApiKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + + // 权限检查 + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login'); + } else if (status === 'authenticated' && session?.user) { + const userRole = (session.user as any).role; + if (userRole !== 'ADMIN' && userRole !== 'SYSTEM_ADMIN') { + router.push('/'); + } + } + }, [status, session, router]); + + // 加载 API Keys + useEffect(() => { + if (status === 'authenticated') { + fetchApiKeys(); + } + }, [status]); + + const fetchApiKeys = async () => { + try { + const response = await fetch('/api/admin/api-keys'); + if (response.ok) { + const data = await response.json(); + setApiKeys(data.data); + } + } catch (error) { + console.error('加载失败:', error); + } finally { + setLoading(false); + } + }; + + const handleCreate = async () => { + if (!newKeyName.trim()) { + alert('请输入密钥名称'); + return; + } + + try { + const response = await fetch('/api/admin/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName }), + }); + + if (response.ok) { + const data = await response.json(); + setCreatedKey(data.data.key); + setNewKeyName(''); + fetchApiKeys(); + } else { + const error = await response.json(); + alert(error.error || '创建失败'); + } + } catch (error) { + console.error('创建失败:', error); + alert('创建失败'); + } + }; + + const handleToggleActive = async (id: string, isActive: boolean) => { + try { + const response = await fetch(`/api/admin/api-keys/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !isActive }), + }); + + if (response.ok) { + fetchApiKeys(); + } + } catch (error) { + console.error('更新失败:', error); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('确定要删除此 API Key 吗?')) return; + + try { + const response = await fetch(`/api/admin/api-keys/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + fetchApiKeys(); + } + } catch (error) { + console.error('删除失败:', error); + } + }; + + if (loading) { + return
加载中...
; + } + + return ( +
+
+
+

API Key 管理

+ +
+ + {/* API Keys 列表 */} +
+ + + + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + + + ))} + +
名称密钥状态最后使用创建时间操作
{key.name}{key.key.substring(0, 16)}... + + {key.isActive ? '启用' : '禁用'} + + + {key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString('zh-CN') : '从未使用'} + + {new Date(key.createdAt).toLocaleString('zh-CN')} + + + +
+
+ + {/* 创建模态框 */} + {showCreateModal && ( +
+
+

创建 API Key

+ + {createdKey ? ( +
+

API Key 已创建,请妥善保存:

+
+ {createdKey} +
+

注意:此密钥只显示一次,关闭后将无法再次查看

+ +
+ ) : ( +
+ + setNewKeyName(e.target.value)} + placeholder="例如:任务系统" + className="w-full px-3 py-2 border rounded mb-4" + /> +
+ + +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/app/admin/api-logs/page.tsx b/app/admin/api-logs/page.tsx new file mode 100644 index 0000000..3e11c4b --- /dev/null +++ b/app/admin/api-logs/page.tsx @@ -0,0 +1,325 @@ +/** + * API 调用日志页面 + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; + +interface ApiLog { + id: string; + apiKeyName: string; + endpoint: string; + method: string; + statusCode: number; + responseTime: number; + requestBody: any; + responseBody: any; + taskNo: string | null; + createdAt: string; +} + +export default function ApiLogsPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedLog, setSelectedLog] = useState(null); + + // 筛选条件 + const [taskNo, setTaskNo] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const limit = 50; + + // 权限检查 + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login'); + } else if (status === 'authenticated' && session?.user) { + const userRole = (session.user as any).role; + if (userRole !== 'ADMIN' && userRole !== 'SYSTEM_ADMIN') { + router.push('/'); + } + } + }, [status, session, router]); + + // 加载日志 + useEffect(() => { + if (status === 'authenticated') { + fetchLogs(); + } + }, [status, offset]); + + const fetchLogs = async () => { + try { + setLoading(true); + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + + if (taskNo) params.append('taskNo', taskNo); + if (startDate) params.append('startDate', startDate); + if (endDate) params.append('endDate', endDate); + + const response = await fetch(`/api/admin/api-logs?${params}`); + if (response.ok) { + const result = await response.json(); + setLogs(result.data.logs); + setTotal(result.data.total); + } + } catch (error) { + console.error('加载失败:', error); + } finally { + setLoading(false); + } + }; + + const handleSearch = () => { + setOffset(0); + fetchLogs(); + }; + + const handleReset = () => { + setTaskNo(''); + setStartDate(''); + setEndDate(''); + setOffset(0); + setTimeout(fetchLogs, 0); + }; + + const getStatusColor = (statusCode: number) => { + if (statusCode >= 200 && statusCode < 300) return 'text-green-600'; + if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600'; + return 'text-red-600'; + }; + + if (loading && logs.length === 0) { + return
加载中...
; + } + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+
+

API 调用日志

+ + {/* 筛选条件 */} +
+
+
+ + setTaskNo(e.target.value)} + placeholder="输入任务号" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setStartDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setEndDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ + {/* 统计信息 */} +
+ 共 {total} 条记录,当前显示第 {offset + 1} - {Math.min(offset + limit, total)} 条 +
+ + {/* 日志列表 */} +
+
+ + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + + ))} + +
时间API Key任务号接口方法状态码响应时间操作
+ {new Date(log.createdAt).toLocaleString('zh-CN')} + {log.apiKeyName} + {log.taskNo || '-'} + {log.endpoint} + {log.method} + + + {log.statusCode} + + + {log.responseTime}ms + + +
+
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + + 第 {currentPage} / {totalPages} 页 + + +
+ )} + + {/* 详情模态框 */} + {selectedLog && ( +
+
+
+

调用详情

+ +
+ +
+
+
+ 时间: + + {new Date(selectedLog.createdAt).toLocaleString('zh-CN')} + +
+
+ API Key: + {selectedLog.apiKeyName} +
+
+ 任务号: + {selectedLog.taskNo || '-'} +
+
+ 接口: + {selectedLog.endpoint} +
+
+ 方法: + {selectedLog.method} +
+
+ 状态码: + + {selectedLog.statusCode} + +
+
+ 响应时间: + {selectedLog.responseTime}ms +
+
+ + {selectedLog.requestBody && ( +
+

请求体

+
+                      {JSON.stringify(selectedLog.requestBody, null, 2)}
+                    
+
+ )} + + {selectedLog.responseBody && ( +
+

响应体

+
+                      {JSON.stringify(selectedLog.responseBody, null, 2)}
+                    
+
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index fa99e7d..7db8302 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -72,6 +72,25 @@ interface ProductCategory { interface Department { id: string name: string + // 阶段三:部门层级结构字段 + code?: string | null + parentId?: string | null + level?: number + managerId?: string | null + status?: string + description?: string | null + sortOrder?: number + createdAt?: string + updatedAt?: string + // 关联数据(可选) + parent?: Department | null + children?: Department[] + manager?: { + id: string + email: string + username: string + realName?: string | null + } | null } export default function AdminPage() { @@ -192,10 +211,13 @@ export default function AdminPage() { const fetchDepartments = useCallback(async () => { try { - const response = await fetch('/api/departments') + // 阶段三:获取包含层级关系的部门数据 + const response = await fetch('/api/departments?includeHierarchy=true') if (response.ok) { const data = await response.json() setDepartments(data) + // 默认所有部门收起 + setCollapsedDepartments(new Set(data.map((d: Department) => d.id))) } } catch (error) { console.error('Error fetching departments:', error) diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 7248df0..3cfc09d 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -240,7 +240,7 @@ export default function SystemSettings() { ) } - if (!session || session.user.role !== 'ADMIN') { + if (!session || (session.user as any)?.role !== 'ADMIN') { return null } diff --git a/app/api/admin/api-keys/[id]/route.ts b/app/api/admin/api-keys/[id]/route.ts new file mode 100644 index 0000000..aa2f1bf --- /dev/null +++ b/app/api/admin/api-keys/[id]/route.ts @@ -0,0 +1,143 @@ +/** + * 单个 API Key 管理接口 + * PUT - 启用/禁用 API Key + * DELETE - 删除 API Key + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission'; +import { enableApiKey, disableApiKey } from '@/lib/external-api'; +import { prisma } from '@/lib/prisma'; + +/** + * PUT - 启用/禁用 API Key + */ +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + // 检查权限 + const isSuperAdminUser = await isSuperAdmin(session.user.id); + const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser); + + if (!hasPermission) { + return NextResponse.json( + { success: false, error: '无权限操作' }, + { status: 403 } + ); + } + + const { id } = params; + const body = await request.json(); + const { isActive } = body; + + if (typeof isActive !== 'boolean') { + return NextResponse.json( + { success: false, error: '参数错误' }, + { status: 400 } + ); + } + + // 验证 API Key 是否存在 + const apiKey: any = await prisma.apiKey.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!apiKey) { + return NextResponse.json( + { success: false, error: 'API Key 不存在' }, + { status: 404 } + ); + } + + // 更新状态 + if (isActive) { + await enableApiKey(id); + } else { + await disableApiKey(id); + } + + return NextResponse.json({ + success: true, + message: isActive ? 'API Key 已启用' : 'API Key 已禁用', + }); + } catch (error) { + console.error('[admin/api-keys/[id]] PUT error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} + +/** + * DELETE - 删除 API Key + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + // 检查权限 + const isSuperAdminUser = await isSuperAdmin(session.user.id); + const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser); + + if (!hasPermission) { + return NextResponse.json( + { success: false, error: '无权限操作' }, + { status: 403 } + ); + } + + const { id } = params; + + // 验证 API Key 是否存在 + const apiKey: any = await prisma.apiKey.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!apiKey) { + return NextResponse.json( + { success: false, error: 'API Key 不存在' }, + { status: 404 } + ); + } + + // 删除 API Key(级联删除相关日志) + await prisma.apiKey.delete({ + where: { id }, + }); + + return NextResponse.json({ + success: true, + message: 'API Key 已删除', + }); + } catch (error) { + console.error('[admin/api-keys/[id]] DELETE error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/api-keys/route.ts b/app/api/admin/api-keys/route.ts new file mode 100644 index 0000000..067ef06 --- /dev/null +++ b/app/api/admin/api-keys/route.ts @@ -0,0 +1,106 @@ +/** + * API Key 管理接口 + * GET - 获取 API Key 列表 + * POST - 创建新的 API Key + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission'; +import { getApiKeys, createApiKey } from '@/lib/external-api'; + +/** + * GET - 获取 API Key 列表 + */ +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + // 检查权限(仅超级管理员和系统管理员可访问) + const isSuperAdminUser = await isSuperAdmin(session.user.id); + const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser); + + if (!hasPermission) { + return NextResponse.json( + { success: false, error: '无权限访问' }, + { status: 403 } + ); + } + + const apiKeys = await getApiKeys(); + + // 隐藏完整 key,只显示前8位 + const maskedKeys = apiKeys.map((k: any) => ({ + ...k, + key: k.key.substring(0, 8) + '...', + })); + + return NextResponse.json({ + success: true, + data: maskedKeys, + }); + } catch (error) { + console.error('[admin/api-keys] GET error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} + +/** + * POST - 创建新的 API Key + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + // 检查权限 + const isSuperAdminUser = await isSuperAdmin(session.user.id); + const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser); + + if (!hasPermission) { + return NextResponse.json( + { success: false, error: '无权限操作' }, + { status: 403 } + ); + } + + const body = await request.json(); + const { name } = body; + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json( + { success: false, error: '密钥名称不能为空' }, + { status: 400 } + ); + } + + const apiKey = await createApiKey(name.trim(), session.user.id); + + return NextResponse.json({ + success: true, + data: apiKey, + message: 'API Key 创建成功,请妥善保管密钥,此密钥仅显示一次', + }); + } catch (error) { + console.error('[admin/api-keys] POST error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/api-logs/route.ts b/app/api/admin/api-logs/route.ts new file mode 100644 index 0000000..e1a7b79 --- /dev/null +++ b/app/api/admin/api-logs/route.ts @@ -0,0 +1,73 @@ +/** + * API 调用日志接口 + * GET - 获取调用日志列表 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission'; +import { getApiCallLogs } from '@/lib/external-api'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + // 检查权限 + const isSuperAdminUser = await isSuperAdmin(session.user.id); + const hasPermission = hasSystemAdminPermission(session.user.role, isSuperAdminUser); + + if (!hasPermission) { + return NextResponse.json( + { success: false, error: '无权限访问' }, + { status: 403 } + ); + } + + const { searchParams } = new URL(request.url); + const apiKeyId = searchParams.get('apiKeyId') || undefined; + const taskNo = searchParams.get('taskNo') || undefined; + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + const limit = parseInt(searchParams.get('limit') || '50'); + const offset = parseInt(searchParams.get('offset') || '0'); + + const options: any = { + apiKeyId, + taskNo, + limit, + offset, + }; + + if (startDate) { + options.startDate = new Date(startDate); + } + if (endDate) { + options.endDate = new Date(endDate); + } + + const result = await getApiCallLogs(options); + + return NextResponse.json({ + success: true, + data: { + logs: result.logs, + total: result.total, + limit, + offset, + }, + }); + } catch (error) { + console.error('[admin/api-logs] GET error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/department-permissions/inherit/route.ts b/app/api/admin/department-permissions/inherit/route.ts new file mode 100644 index 0000000..d8f6044 --- /dev/null +++ b/app/api/admin/department-permissions/inherit/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { isSuperAdmin } from '@/lib/department-permission'; + +/** + * DELETE - 删除部门的所有自定义权限配置,使其恢复为继承父部门权限 + */ +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const department = searchParams.get('department'); + + if (!department) { + return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); + } + + // 删除该部门的所有权限配置(不包括关联的,因为关联的需要先取消关联) + await prisma.departmentMaterialPermission.deleteMany({ + where: { + department, + linkedToDepartment: null, // 只删除非关联的权限配置 + }, + }); + + return NextResponse.json({ + message: '已还原为继承父部门权限', + department, + }); + } catch (error) { + console.error('[Department Permissions Inherit] DELETE Error:', error); + return NextResponse.json({ error: '还原失败' }, { status: 500 }); + } +} + +/** + * POST - 基于父部门权限创建独立配置 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const body = await request.json(); + const { department } = body; + + if (!department) { + return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); + } + + // 获取部门信息(包括父部门) + const dept = await prisma.department.findFirst({ + where: { name: department }, + include: { parent: true }, + }); + + if (!dept) { + return NextResponse.json({ error: '部门不存在' }, { status: 404 }); + } + + if (!dept.parentId) { + return NextResponse.json({ error: '该部门没有父部门,无法继承' }, { status: 400 }); + } + + // 获取父部门的权限配置 + const { getDepartmentPermissions, getAllMaterialTypes } = await import('@/lib/department-permission'); + const allMaterialTypes = await getAllMaterialTypes(); + + // 获取所有部门用于权限继承检查 + const allDepartments = await prisma.department.findMany({ + select: { id: true, name: true, parentId: true }, + }); + + const parentPermissions = await getDepartmentPermissions( + dept.parent!.name, + allDepartments, + allMaterialTypes + ); + + // 检查是否已经有自定义权限配置 + const existingCount = await prisma.departmentMaterialPermission.count({ + where: { + department, + linkedToDepartment: null, + }, + }); + + if (existingCount > 0) { + return NextResponse.json( + { error: '该部门已有自定义权限配置,请先还原为继承后再操作' }, + { status: 400 } + ); + } + + // 基于父部门权限创建独立配置 + const newPermissions = allMaterialTypes.map((materialType) => { + const parentPerm = parentPermissions[materialType]; + return { + department, + materialType, + canView: parentPerm?.canView ?? false, + canDownload: parentPerm?.canDownload ?? false, + canShare: parentPerm?.canShare ?? true, + linkedToDepartment: null, + }; + }); + + await prisma.departmentMaterialPermission.createMany({ + data: newPermissions, + }); + + return NextResponse.json({ + message: '已创建独立配置', + department, + createdCount: newPermissions.length, + }); + } catch (error) { + console.error('[Department Permissions Inherit] POST Error:', error); + return NextResponse.json({ error: '创建独立配置失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/department-permissions/route.ts b/app/api/admin/department-permissions/route.ts index ecbd607..8b5480a 100644 --- a/app/api/admin/department-permissions/route.ts +++ b/app/api/admin/department-permissions/route.ts @@ -37,6 +37,7 @@ export async function GET(request: NextRequest) { // 获取所有部门的权限配置 const permissionsMap: Record> = {}; const linkedDepartments: Record = {}; + const hasCustomPermissions: Record = {}; // 记录哪些部门有自定义权限配置 for (const dept of filteredDepartments) { const permissions = await getDepartmentPermissions(dept.name); @@ -53,15 +54,25 @@ export async function GET(request: NextRequest) { if (deptPermissions?.linkedToDepartment) { linkedDepartments[dept.name] = deptPermissions.linkedToDepartment; } + + // 检查是否有自定义权限配置(不是继承的) + const customPermCount = await prisma.departmentMaterialPermission.count({ + where: { + department: dept.name, + linkedToDepartment: null, // 没有关联到其他部门 + }, + }); + hasCustomPermissions[dept.name] = customPermCount > 0; } // 从数据库动态获取所有素材类型 const materialTypes = await getAllMaterialTypes(); return NextResponse.json({ - departments: filteredDepartments.map((d) => d.name), + departments: filteredDepartments.map((d) => ({ id: d.id, name: d.name, parentId: d.parentId })), permissions: permissionsMap, linkedDepartments, + hasCustomPermissions, materialTypes, }); } catch (error) { diff --git a/app/api/admin/super-admins/route.ts b/app/api/admin/super-admins/route.ts index 3d2f900..536f517 100644 --- a/app/api/admin/super-admins/route.ts +++ b/app/api/admin/super-admins/route.ts @@ -82,9 +82,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '该用户已经是超级管理员' }, { status: 400 }); } - // 检查是否是管理员角色 - if (targetUser.role !== 'ADMIN') { - return NextResponse.json({ error: '只有管理员才能被设置为超级管理员' }, { status: 400 }); + // 检查是否是管理员角色(ADMIN或SYSTEM_ADMIN) + const { isSystemAdmin: isSystemAdminCheck } = await import('@/lib/department-permission'); + if (!isSystemAdminCheck(targetUser.role)) { + return NextResponse.json({ error: '只有系统管理员才能被设置为超级管理员' }, { status: 400 }); } // 设置为超级管理员 diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 8024abc..246d99e 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; // GET /api/admin/users - 获取用户列表 export async function GET() { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json({ error: '无权限' }, { status: 403 }); } @@ -18,11 +19,22 @@ export async function GET() { username: true, realName: true, // 真实姓名(用于搜索) role: true, - isActive: true, + isActive: true, // 保留,向后兼容 + status: true, // 新增:用户状态(阶段一) canDownload: true, - department: true, + department: true, // 保留,向后兼容 + departmentId: true, // 新增:部门ID(阶段二) managedDepartment: true, // 管理员负责的部门 createdAt: true, + lastLoginAt: true, // 新增:最后登录时间(阶段一) + loginCount: true, // 新增:登录次数(阶段一) + archivedAt: true, // 新增:归档时间(阶段一) + departmentRef: { // 新增:部门关联信息(阶段二) + select: { + id: true, + name: true, + } + }, _count: { select: { materials: { @@ -49,7 +61,7 @@ export async function GET() { export async function DELETE(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json({ error: '无权限' }, { status: 403 }); } diff --git a/app/api/admin/users/set-department-manager/route.ts b/app/api/admin/users/set-department-manager/route.ts index 42fbd09..f057cc7 100644 --- a/app/api/admin/users/set-department-manager/route.ts +++ b/app/api/admin/users/set-department-manager/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; /** * 设置部门管理员 @@ -11,7 +12,7 @@ export async function POST(request: NextRequest) { try { // 验证管理员权限 const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json( { error: '需要管理员权限' }, { status: 403 } @@ -72,7 +73,7 @@ export async function DELETE(request: NextRequest) { try { // 验证管理员权限 const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json( { error: '需要管理员权限' }, { status: 403 } diff --git a/app/api/admin/users/set-role/route.ts b/app/api/admin/users/set-role/route.ts index 2933517..08992c4 100644 --- a/app/api/admin/users/set-role/route.ts +++ b/app/api/admin/users/set-role/route.ts @@ -3,12 +3,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; -// POST - 设置用户角色(仅管理员) +// POST - 设置用户角色(仅系统管理员) export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user?.role !== 'ADMIN') { + // 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以 + if (!session || !isSystemAdmin(session.user?.role)) { return NextResponse.json( { error: '权限不足' }, { status: 403 } @@ -24,9 +26,11 @@ export async function POST(request: NextRequest) { ); } - if (role !== 'USER' && role !== 'ADMIN') { + // 支持所有角色:USER, ADMIN(兼容), DEPT_ADMIN, SYSTEM_ADMIN + const validRoles = ['USER', 'ADMIN', 'DEPT_ADMIN', 'SYSTEM_ADMIN']; + if (!validRoles.includes(role)) { return NextResponse.json( - { error: '角色值无效' }, + { error: '角色值无效,支持的角色:' + validRoles.join(', ') }, { status: 400 } ); } diff --git a/app/api/admin/users/toggle-active/route.ts b/app/api/admin/users/toggle-active/route.ts index a194542..27dd7b0 100644 --- a/app/api/admin/users/toggle-active/route.ts +++ b/app/api/admin/users/toggle-active/route.ts @@ -3,23 +3,55 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; // POST - 切换用户激活状态(仅管理员) export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '无权限' }, { status: 403 } ); } - const { userId, isActive } = await request.json(); + const { userId, isActive, status } = await request.json(); - if (!userId || typeof isActive !== 'boolean') { + // 支持新旧两种参数格式 + let targetStatus: string; + let targetIsActive: boolean; + + if (status !== undefined) { + // 新格式:使用 status 参数 + if (!['pending', 'active', 'suspended', 'archived'].includes(status)) { + return NextResponse.json( + { error: '无效的状态值' }, + { status: 400 } + ); + } + targetStatus = status; + targetIsActive = status === 'active'; + } else if (isActive !== undefined) { + // 旧格式:使用 isActive 参数(向后兼容) + if (typeof isActive !== 'boolean') { + return NextResponse.json( + { error: '参数错误' }, + { status: 400 } + ); + } + targetIsActive = isActive; + targetStatus = isActive ? 'active' : 'pending'; + } else { return NextResponse.json( - { error: '参数错误' }, + { error: '缺少参数:需要 isActive 或 status' }, + { status: 400 } + ); + } + + if (!userId) { + return NextResponse.json( + { error: '缺少用户ID' }, { status: 400 } ); } @@ -32,14 +64,34 @@ export async function POST(request: NextRequest) { ); } - // 更新激活状态 + // 双写:同时更新 status 和 isActive + const updateData: any = { + isActive: targetIsActive, // 旧字段(向后兼容) + status: targetStatus, // 新字段(阶段一新增) + }; + + // 如果状态为 archived,设置 archivedAt + if (targetStatus === 'archived') { + updateData.archivedAt = new Date(); + } else if (targetStatus !== 'archived') { + // 如果从 archived 状态恢复,清除 archivedAt + updateData.archivedAt = null; + } + await prisma.user.update({ where: { id: userId }, - data: { isActive }, + data: updateData, }); + const statusMessages: Record = { + 'pending': '用户已设置为待激活', + 'active': '用户已激活', + 'suspended': '用户已暂停', + 'archived': '用户已归档', + }; + return NextResponse.json({ - message: isActive ? '用户已激活' : '用户已禁用', + message: statusMessages[targetStatus] || (targetIsActive ? '用户已激活' : '用户已禁用'), }); } catch (error) { console.error('Toggle active error:', error); diff --git a/app/api/admin/users/toggle-download/route.ts b/app/api/admin/users/toggle-download/route.ts index e2f5752..4e6edd4 100644 --- a/app/api/admin/users/toggle-download/route.ts +++ b/app/api/admin/users/toggle-download/route.ts @@ -3,12 +3,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; // POST - 切换用户下载权限(仅管理员) export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '无权限' }, { status: 403 } diff --git a/app/api/admin/users/update-department/route.ts b/app/api/admin/users/update-department/route.ts index b5ca6a4..07242f8 100644 --- a/app/api/admin/users/update-department/route.ts +++ b/app/api/admin/users/update-department/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest) { try { // 验证管理员权限 const session = await getServerSession(authOptions); - if (!session || session.user?.role !== 'ADMIN') { + if (!session || !session.user || (session.user as any).role !== 'ADMIN') { return NextResponse.json( { error: '权限不足' }, { status: 403 } @@ -29,11 +29,34 @@ export async function POST(request: NextRequest) { ); } + // 双写:同时更新 department(旧字段)和 departmentId(新字段) + let updateData: any = { + department: department || '' // 旧字段(向后兼容) + }; + + // 如果提供了部门名称,查找对应的部门ID + if (department && department.trim() !== '') { + const dept = await prisma.department.findUnique({ + where: { name: department.trim() } + }); + + if (dept) { + updateData.departmentId = dept.id; // 新字段(阶段二新增) + } else { + // 部门不存在,清除 departmentId + updateData.departmentId = null; + } + } else { + // 部门为空,清除 departmentId + updateData.departmentId = null; + } + // 更新用户部门 const user = await prisma.user.update({ where: { id: userId }, - data: { - department: department || '' + data: updateData, + include: { + departmentRef: true // 包含部门关联信息 } }); diff --git a/app/api/departments/[id]/route.ts b/app/api/departments/[id]/route.ts new file mode 100644 index 0000000..1680fe6 --- /dev/null +++ b/app/api/departments/[id]/route.ts @@ -0,0 +1,386 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; + +// GET /api/departments/[id] - 获取单个部门详情 +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const department = await prisma.department.findUnique({ + where: { id: params.id }, + include: { + parent: { + select: { + id: true, + name: true, + code: true, + level: true, + }, + }, + children: { + select: { + id: true, + name: true, + code: true, + level: true, + status: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + }, + manager: { + select: { + id: true, + email: true, + username: true, + realName: true, + }, + }, + users: { + select: { + id: true, + email: true, + username: true, + realName: true, + role: true, + status: true, + }, + }, + }, + }); + + if (!department) { + return NextResponse.json({ error: '部门不存在' }, { status: 404 }); + } + + return NextResponse.json(department); + } catch (error) { + console.error('Error fetching department:', error); + return NextResponse.json({ error: '获取部门详情失败' }, { status: 500 }); + } +} + +// PUT /api/departments/[id] - 更新部门 +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + // 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以管理部门 + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { + return NextResponse.json({ error: '无权限' }, { status: 403 }); + } + + const body = await request.json(); + const { + name, + parentId: rawParentId, + code, + level, + managerId, + status, + description, + sortOrder, + } = body; + + // 规范化parentId:空字符串转换为null + const parentId = rawParentId === '' || rawParentId === undefined ? null : rawParentId; + + // 检查部门是否存在 + const existing = await prisma.department.findUnique({ + where: { id: params.id }, + select: { + id: true, + name: true, + parentId: true, + level: true, + managerId: true, + children: { + select: { id: true }, + }, + }, + }); + + if (!existing) { + return NextResponse.json({ error: '部门不存在' }, { status: 404 }); + } + + // 构建更新数据 + const updateData: any = {}; + + if (name !== undefined) { + if (!name || name.trim() === '') { + return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); + } + + // 检查新名称是否与其他部门冲突 + const nameConflict = await prisma.department.findFirst({ + where: { + name, + id: { not: params.id }, + }, + }); + + if (nameConflict) { + return NextResponse.json({ error: '部门名称已存在' }, { status: 400 }); + } + + updateData.name = name; + } + + // 处理父部门变更 + if (parentId !== undefined) { + if (parentId === null) { + // 移除父部门,设置为一级部门 + updateData.parentId = null; + updateData.level = 1; + } else { + // 验证父部门 + if (parentId === params.id) { + return NextResponse.json({ error: '不能将自己设为父部门' }, { status: 400 }); + } + + // 检查是否会造成循环引用(不能将自己的子部门设为父部门) + const isDescendant = await checkIsDescendant(params.id, parentId); + if (isDescendant) { + return NextResponse.json({ error: '不能将子部门设为父部门' }, { status: 400 }); + } + + const parent = await prisma.department.findUnique({ + where: { id: parentId }, + select: { level: true, status: true }, + }); + + if (!parent) { + return NextResponse.json({ error: '父部门不存在' }, { status: 400 }); + } + + if (parent.status !== 'active') { + return NextResponse.json({ error: '父部门未启用' }, { status: 400 }); + } + + const newLevel = (parent.level ?? 1) + 1; + if (newLevel > 3) { + return NextResponse.json({ error: '部门层级不能超过3级' }, { status: 400 }); + } + + updateData.parentId = parentId; + updateData.level = newLevel; + } + } + + // 其他可选字段 + if (code !== undefined) updateData.code = code; + // 注意:level字段应该由parentId自动计算,不应该手动设置 + // 如果传入了level,忽略它(因为我们已经根据parentId计算了level) + if (managerId !== undefined) { + updateData.managerId = managerId; + + // 处理部门管理员角色变更 + // 1. 如果之前有管理员,且管理员发生变化(包括设置为null),取消其管理员角色(如果不再是任何部门的管理员) + if (existing.managerId && existing.managerId !== managerId) { + const oldManager = await prisma.user.findUnique({ + where: { id: existing.managerId }, + select: { id: true }, + }); + + if (oldManager) { + // 检查旧管理员是否还是其他部门的管理员 + const stillManagerOf = await prisma.department.findFirst({ + where: { + managerId: existing.managerId, + id: { not: params.id }, + }, + }); + + // 如果不是其他部门的管理员,且角色是DEPT_ADMIN,则降级为USER + if (!stillManagerOf) { + await prisma.user.update({ + where: { id: existing.managerId }, + data: { role: 'USER' }, + }); + } + } + } + + // 2. 如果设置了新管理员,将其角色设置为DEPT_ADMIN + if (managerId) { + await prisma.user.update({ + where: { id: managerId }, + data: { role: 'DEPT_ADMIN' }, + }); + } + } + if (status !== undefined) { + if (!['active', 'disabled', 'archived'].includes(status)) { + return NextResponse.json({ error: '无效的状态值' }, { status: 400 }); + } + updateData.status = status; + + // 如果禁用部门,检查是否有子部门 + if (status === 'disabled' && existing.children.length > 0) { + return NextResponse.json( + { error: '该部门有子部门,不能禁用。请先禁用或移动所有子部门' }, + { status: 400 } + ); + } + } + if (description !== undefined) updateData.description = description; + if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + + const updated = await prisma.department.update({ + where: { id: params.id }, + data: updateData, + include: { + parent: { + select: { + id: true, + name: true, + code: true, + level: true, + }, + }, + manager: { + select: { + id: true, + email: true, + username: true, + realName: true, + }, + }, + }, + }); + + return NextResponse.json(updated); + } catch (error: any) { + console.error('Error updating department:', error); + console.error('Error details:', { + message: error.message, + code: error.code, + meta: error.meta, + stack: error.stack, + }); + + // 处理唯一约束错误 + if (error.code === 'P2002') { + if (error.meta?.target?.includes('code')) { + return NextResponse.json({ error: '部门编码已存在' }, { status: 400 }); + } + if (error.meta?.target?.includes('name')) { + return NextResponse.json({ error: '部门名称已存在' }, { status: 400 }); + } + } + + // 处理外键约束错误 + if (error.code === 'P2003') { + return NextResponse.json({ error: '关联数据不存在或无效' }, { status: 400 }); + } + + // 返回更详细的错误信息(开发环境) + const errorMessage = process.env.NODE_ENV === 'development' + ? `更新部门失败: ${error.message || '未知错误'}` + : '更新部门失败'; + + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} + +// DELETE /api/departments/[id] - 删除部门 +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + // 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以管理部门 + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { + return NextResponse.json({ error: '无权限' }, { status: 403 }); + } + + // 检查部门是否存在 + const department = await prisma.department.findUnique({ + where: { id: params.id }, + include: { + children: { + select: { id: true, name: true }, + }, + users: { + select: { id: true }, + }, + }, + }); + + if (!department) { + return NextResponse.json({ error: '部门不存在' }, { status: 404 }); + } + + // 检查是否有子部门 + if (department.children.length > 0) { + return NextResponse.json( + { error: '该部门有子部门,不能删除。请先删除或移动所有子部门' }, + { status: 400 } + ); + } + + // 检查是否有用户 + if (department.users.length > 0) { + return NextResponse.json( + { error: '该部门有用户,不能删除。请先将用户移动到其他部门' }, + { status: 400 } + ); + } + + await prisma.department.delete({ + where: { id: params.id }, + }); + + return NextResponse.json({ message: '删除成功' }); + } catch (error) { + console.error('Error deleting department:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); + } +} + +// 辅助函数:检查 targetId 是否是 sourceId 的后代 +async function checkIsDescendant(sourceId: string, targetId: string): Promise { + // 如果相同,直接返回false(自己不是自己的后代) + if (sourceId === targetId) return false; + + // 使用迭代方式递归查询所有子部门 + const allDescendantIds = new Set(); + const queue: string[] = [sourceId]; + const visited = new Set(); + + while (queue.length > 0) { + const currentId = queue.shift()!; + + // 避免循环引用 + if (visited.has(currentId)) continue; + visited.add(currentId); + + // 查询当前部门的所有直接子部门 + const children = await prisma.department.findMany({ + where: { parentId: currentId }, + select: { id: true }, + }); + + for (const child of children) { + allDescendantIds.add(child.id); + // 如果找到目标ID,立即返回true + if (child.id === targetId) { + return true; + } + queue.push(child.id); + } + } + + return allDescendantIds.has(targetId); +} + diff --git a/app/api/departments/route.ts b/app/api/departments/route.ts index c7bf053..33066c6 100644 --- a/app/api/departments/route.ts +++ b/app/api/departments/route.ts @@ -2,16 +2,78 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; -import { createDefaultPermissionsForDepartment } from '@/lib/department-permission'; +import { createDefaultPermissionsForDepartment, isSystemAdmin } from '@/lib/department-permission'; // GET /api/departments - 获取部门列表 -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url); + const includeHierarchy = searchParams.get('includeHierarchy') === 'true'; + const status = searchParams.get('status'); // 可选:active/disabled/archived + + // 构建查询条件 + const where: any = {}; + if (status) { + // 如果指定了status参数,使用它 + where.status = status; + } + // 注意:如果不指定status,返回所有部门(包括向后兼容的情况) + + if (includeHierarchy) { + // 新方式:支持层级查询 + const departments = await prisma.department.findMany({ + where, + include: { + parent: { + select: { + id: true, + name: true, + code: true, + level: true, + }, + }, + children: { + select: { + id: true, + name: true, + code: true, + level: true, + status: true, + }, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + }, + manager: { + select: { + id: true, + email: true, + username: true, + realName: true, + }, + }, + }, + orderBy: [ + { level: 'asc' }, + { sortOrder: 'asc' }, + { name: 'asc' }, + ], + }); + + return NextResponse.json(departments); + } else { + // 旧方式:扁平列表(向后兼容) const departments = await prisma.department.findMany({ - orderBy: { createdAt: 'asc' }, + where, + orderBy: [ + { sortOrder: 'asc' }, + { name: 'asc' }, + ], }); return NextResponse.json(departments); + } } catch (error) { console.error('Error fetching departments:', error); return NextResponse.json({ error: '获取部门列表失败' }, { status: 500 }); @@ -22,11 +84,22 @@ export async function GET() { export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + // 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以管理部门 + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json({ error: '无权限' }, { status: 403 }); } - const { name } = await request.json(); + const body = await request.json(); + const { + name, + parentId, + code, + level, + managerId, + status, + description, + sortOrder + } = body; if (!name || name.trim() === '') { return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); @@ -41,8 +114,53 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '部门已存在' }, { status: 400 }); } + // 如果指定了parentId,验证父部门是否存在 + let calculatedLevel = level ?? 1; + if (parentId) { + const parent = await prisma.department.findUnique({ + where: { id: parentId }, + select: { level: true, status: true }, + }); + + if (!parent) { + return NextResponse.json({ error: '父部门不存在' }, { status: 400 }); + } + + if (parent.status !== 'active') { + return NextResponse.json({ error: '父部门未启用,无法创建子部门' }, { status: 400 }); + } + + // 计算层级(父部门层级 + 1) + calculatedLevel = (parent.level ?? 1) + 1; + + if (calculatedLevel > 3) { + return NextResponse.json({ error: '部门层级不能超过3级' }, { status: 400 }); + } + } + + // 构建创建数据 + const createData: any = { + name, + level: calculatedLevel, + status: status ?? 'active', + sortOrder: sortOrder ?? 0, + }; + + // 可选字段 + if (parentId) createData.parentId = parentId; + if (code) createData.code = code; + if (managerId) { + createData.managerId = managerId; + // 设置部门管理员时,自动将用户角色设置为DEPT_ADMIN + await prisma.user.update({ + where: { id: managerId }, + data: { role: 'DEPT_ADMIN' }, + }); + } + if (description) createData.description = description; + const department = await prisma.department.create({ - data: { name }, + data: createData, }); // 自动创建默认权限配置 @@ -54,17 +172,29 @@ export async function POST(request: NextRequest) { } return NextResponse.json(department, { status: 201 }); - } catch (error) { + } catch (error: any) { console.error('Error creating department:', error); + + // 处理唯一约束错误 + if (error.code === 'P2002') { + if (error.meta?.target?.includes('code')) { + return NextResponse.json({ error: '部门编码已存在' }, { status: 400 }); + } + if (error.meta?.target?.includes('name')) { + return NextResponse.json({ error: '部门名称已存在' }, { status: 400 }); + } + } + return NextResponse.json({ error: '创建部门失败' }, { status: 500 }); } } -// DELETE /api/departments - 删除部门 +// DELETE /api/departments - 删除部门(保留向后兼容,建议使用 DELETE /api/departments/[id]) export async function DELETE(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + // 使用兼容性检查:ADMIN 和 SYSTEM_ADMIN 都可以管理部门 + if (!session || !session.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json({ error: '无权限' }, { status: 403 }); } @@ -75,6 +205,39 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: '缺少部门ID' }, { status: 400 }); } + // 检查部门是否存在 + const department = await prisma.department.findUnique({ + where: { id }, + include: { + children: { + select: { id: true, name: true }, + }, + users: { + select: { id: true }, + }, + }, + }); + + if (!department) { + return NextResponse.json({ error: '部门不存在' }, { status: 404 }); + } + + // 检查是否有子部门 + if (department.children && department.children.length > 0) { + return NextResponse.json( + { error: '该部门有子部门,不能删除。请先删除或移动所有子部门' }, + { status: 400 } + ); + } + + // 检查是否有用户 + if (department.users && department.users.length > 0) { + return NextResponse.json( + { error: '该部门有用户,不能删除。请先将用户移动到其他部门' }, + { status: 400 } + ); + } + await prisma.department.delete({ where: { id }, }); diff --git a/app/api/dingtalk/complete-profile/route.ts b/app/api/dingtalk/complete-profile/route.ts index b3bb852..147cf78 100644 --- a/app/api/dingtalk/complete-profile/route.ts +++ b/app/api/dingtalk/complete-profile/route.ts @@ -26,11 +26,34 @@ export async function POST(request: NextRequest) { ) } - // 直接更新部门信息(用户名已在登录时设置) + // 双写:同时更新 department(旧字段)和 departmentId(新字段) + let updateData: any = { + department: department // 旧字段(向后兼容) + }; + + // 查找对应的部门ID + if (department && department.trim() !== '') { + const dept = await prisma.department.findUnique({ + where: { name: department.trim() } + }); + + if (dept) { + updateData.departmentId = dept.id; // 新字段(阶段二新增) + } else { + // 部门不存在,清除 departmentId + updateData.departmentId = null; + } + } else { + // 部门为空,清除 departmentId + updateData.departmentId = null; + } + + // 更新用户部门信息 const user = await prisma.user.update({ where: { id: userId }, - data: { - department, + data: updateData, + include: { + departmentRef: true // 包含部门关联信息 } }) diff --git a/app/api/dingtalk/login/route.ts b/app/api/dingtalk/login/route.ts index ed427d2..ba84bcb 100644 --- a/app/api/dingtalk/login/route.ts +++ b/app/api/dingtalk/login/route.ts @@ -278,7 +278,8 @@ export async function POST(request: NextRequest) { phone: userInfo.mobile || null, department: '', // 待用户选择 role: 'USER', - isActive: false, // 新用户默认未激活,需要管理员激活 + isActive: false, // 新用户默认未激活,需要管理员激活(向后兼容) + status: 'pending', // 新用户默认状态为 pending(阶段一新增) loginType: 'dingtalk', dingtalkUserId: userId } @@ -325,14 +326,45 @@ export async function POST(request: NextRequest) { }) } - // 检查用户是否已激活 - if (!user.isActive) { + // 检查用户状态(优先使用 status 字段,兼容 isActive) + const userStatus = (user as any).status || (user.isActive ? 'active' : 'pending'); + const isUserActive = userStatus === 'active'; + + if (!isUserActive) { return NextResponse.json({ success: false, message: '您的账号尚未激活,请联系管理员激活后再登录' }, { status: 403 }) } + // 检查是否被暂停或归档 + if (userStatus === 'suspended' || userStatus === 'archived') { + return NextResponse.json({ + success: false, + message: '您的账号已被暂停或归档,无法登录' + }, { status: 403 }) + } + + // 记录登录审计信息(获取客户端IP) + const clientIp = request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + null; + + await prisma.user.update({ + where: { id: user.id }, + data: { + lastLoginAt: new Date(), + lastLoginIp: clientIp, + loginCount: { increment: 1 }, + // 双写:确保 status 和 isActive 一致 + status: 'active', + isActive: true + } + }).catch(err => { + console.error('记录登录审计信息失败:', err); + // 不阻塞登录流程 + }); + // 返回用户信息(不包含敏感信息) return NextResponse.json({ success: true, diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts new file mode 100644 index 0000000..df6e5fe --- /dev/null +++ b/app/api/external/download/route.ts @@ -0,0 +1,267 @@ +/** + * 外部接口:文件下载 + * GET - 通过 Token 下载文件 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyDownloadToken, isTokenExpired } from '@/lib/download-token'; +import { errorResponse, API_ERROR } from '@/lib/external-api'; +import { prisma } from '@/lib/prisma'; +import { ASSET_TYPE } from '@/lib/product-package'; +import archiver from 'archiver'; +import { Readable } from 'stream'; +import path from 'path'; +import fs from 'fs'; + +/** + * GET - 下载文件 + */ +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get('token'); + + if (!token) { + return errorResponse(API_ERROR.INVALID_TOKEN); + } + + // 检查 Token 是否过期 + if (isTokenExpired(token)) { + return errorResponse(API_ERROR.TOKEN_EXPIRED); + } + + // 验证 Token + const payload = verifyDownloadToken(token); + if (!payload) { + return errorResponse(API_ERROR.INVALID_TOKEN); + } + + const { materialId, downloadType, assetId } = payload; + + try { + // 查询素材 + const material: any = await prisma.material.findUnique({ + where: { id: materialId, deletedAt: null }, + include: { + assets: true, + }, + }); + + if (!material) { + return errorResponse(API_ERROR.TASK_NOT_FOUND); + } + + // 单个资产下载 + 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); + } + + // PSD 下载 + if (downloadType === 'psd') { + // 如果指定了assetId,下载单个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); + } + + // 未指定assetId,查找所有PSD文件 + 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 } + ); + } + + // 如果只有一个PSD文件,直接下载;否则打包 + if (psdAssets.length === 1) { + return await downloadSingleFile(psdAssets[0]); + } + + // 多个PSD文件,打包下载 + return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd'); + } + + // 打包下载 + 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); + } catch (error) { + console.error('[external/download] error:', error); + return errorResponse(API_ERROR.SERVER_ERROR); + } +} + +/** + * 根据下载类型获取资产列表 + */ +function getAssetsForDownload(assets: any[], downloadType: string) { + switch (downloadType) { + case 'main': + return assets.filter(a => a.assetType === ASSET_TYPE.MAIN); + case 'sku': + return assets.filter(a => a.assetType === ASSET_TYPE.SKU); + case 'detail': + return assets.filter(a => a.assetType === ASSET_TYPE.DETAIL); + case 'all': + // 全部下载不含 PSD + return assets.filter(a => a.assetType !== ASSET_TYPE.PSD); + default: + return []; + } +} + +/** + * 下载单个文件 + */ +async function downloadSingleFile(asset: any) { + const fileUrl = asset.fileUrl; + const fileName = asset.fileName || path.basename(fileUrl); + + // 判断是本地文件还是远程文件 + if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) { + // 本地文件 + const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, '')); + + if (!fs.existsSync(localPath)) { + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } }, + { status: 404 } + ); + } + + const fileBuffer = fs.readFileSync(localPath); + const contentType = getContentType(fileName); + + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Length': fileBuffer.length.toString(), + }, + }); + } else { + // 远程文件(S3) + try { + const response = await fetch(fileUrl); + if (!response.ok) { + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } }, + { status: 404 } + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || getContentType(fileName); + + return new NextResponse(arrayBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }); + } catch (error) { + console.error('[downloadSingleFile] fetch error:', error); + return NextResponse.json( + { success: false, error: { code: 'DOWNLOAD_FAILED', message: '下载失败' } }, + { status: 500 } + ); + } + } +} + +/** + * 打包下载为 ZIP + */ +async function downloadAsZip(assets: any[], taskNo: string, downloadType: string) { + return new Promise((resolve, reject) => { + const archive = archiver('zip', { zlib: { level: 5 } }); + const chunks: Buffer[] = []; + + archive.on('data', (chunk) => chunks.push(chunk)); + archive.on('end', () => { + const buffer = Buffer.concat(chunks); + const fileName = `${taskNo}-${downloadType}.zip`; + + resolve(new NextResponse(buffer, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Length': buffer.length.toString(), + }, + })); + }); + archive.on('error', (err) => { + console.error('[downloadAsZip] archive error:', err); + reject(err); + }); + + // 添加文件到压缩包 + (async () => { + for (const asset of assets) { + const fileUrl = asset.fileUrl; + const fileName = asset.fileName || path.basename(fileUrl); + const folder = asset.assetType; // main/sku/detail + + try { + if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) { + // 本地文件 + const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, '')); + if (fs.existsSync(localPath)) { + archive.file(localPath, { name: `${folder}/${fileName}` }); + } + } else { + // 远程文件 + const response = await fetch(fileUrl); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + archive.append(Buffer.from(arrayBuffer), { name: `${folder}/${fileName}` }); + } + } + } catch (error) { + console.error(`[downloadAsZip] 添加文件失败: ${fileName}`, error); + } + } + + archive.finalize(); + })(); + }); +} + +/** + * 根据文件名获取 Content-Type + */ +function getContentType(fileName: string): string { + const ext = path.extname(fileName).toLowerCase(); + const mimeTypes: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.psd': 'application/octet-stream', + '.zip': 'application/zip', + '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', + }; + return mimeTypes[ext] || 'application/octet-stream'; +} diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts new file mode 100644 index 0000000..a99d842 --- /dev/null +++ b/app/api/external/product-assets/route.ts @@ -0,0 +1,285 @@ +/** + * 外部接口:产品全案素材查询和删除 + * GET - 通过任务号查询素材详情 + * DELETE - 通过任务号删除素材 + */ + +import { NextRequest } from 'next/server'; +import { + validateApiKey, + getApiKeyFromRequest, + logApiCall, + errorResponse, + successResponse, + API_ERROR, + getClientIp, +} from '@/lib/external-api'; +import { + validateTaskNo, + getMaterialByTaskNo, + updateTaskStatus, + softDeleteByTaskNo, + TASK_STATUS, + ASSET_TYPE, +} from '@/lib/product-package'; +import { generateDownloadUrl, DownloadType } from '@/lib/download-token'; + +// 获取基础 URL +function getBaseUrl(request: NextRequest): string { + const host = request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + return `${protocol}://${host}`; +} + +/** + * GET - 查询素材详情 + */ +export async function GET(request: NextRequest) { + const startTime = Date.now(); + const apiKey = getApiKeyFromRequest(request); + const taskNo = request.nextUrl.searchParams.get('taskNo'); + + // 验证 API Key + const keyRecord = await validateApiKey(apiKey); + if (!keyRecord) { + return errorResponse(API_ERROR.INVALID_API_KEY); + } + + // 验证任务号格式 + if (!taskNo) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + statusCode: 400, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.INVALID_TASK_NO); + } + + if (!validateTaskNo(taskNo)) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + taskNo, + statusCode: 400, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.INVALID_TASK_NO); + } + + try { + // 查询素材 + const material: any = await getMaterialByTaskNo(taskNo); + + if (!material) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + taskNo, + statusCode: 404, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.TASK_NOT_FOUND); + } + + if (material.deletedAt) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + taskNo, + statusCode: 410, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.MATERIAL_DELETED); + } + + // 更新任务状态为 fetched + if (material.taskStatus !== TASK_STATUS.FETCHED) { + await updateTaskStatus(material.id, TASK_STATUS.FETCHED, { + taskFetchedAt: new Date(), + }); + } + + const baseUrl = getBaseUrl(request); + + // 按类型分组资产 + 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); + + // 生成资产列表(带下载链接) + const formatAsset = (asset: any, downloadType: DownloadType) => ({ + id: asset.id, + url: asset.fileUrl, + downloadUrl: generateDownloadUrl(baseUrl, { + materialId: material.id, + downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType, + assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined, // PSD文件需要传递assetId + }), + fileName: asset.fileName, + fileSize: Number(asset.fileSize), + ...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder }), + }); + + // 构建响应数据 + const responseData = { + taskNo: material.taskNo, + productModel: material.productModel, + categoryName: material.categoryName, + sceneName: material.sceneName, + status: TASK_STATUS.FETCHED, + createdAt: material.createdAt.toISOString(), + updatedAt: material.updatedAt.toISOString(), + assets: { + main: mainAssets.map((a: any) => formatAsset(a, 'main')), + sku: skuAssets.map((a: any) => formatAsset(a, 'sku')), + detail: detailAssets.map((a: any) => formatAsset(a, 'detail')), + psd: psdAssets.map((a: any) => formatAsset(a, 'psd')), + }, + downloadUrls: { + all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), + main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }), + sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }), + detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }), + // PSD批量下载已废弃,请使用单个PSD文件的downloadUrl + psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : 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); + } catch (error) { + console.error('[external/product-assets] GET error:', error); + 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, + }); + return errorResponse(API_ERROR.SERVER_ERROR); + } +} + +/** + * DELETE - 删除素材 + */ +export async function DELETE(request: NextRequest) { + const startTime = Date.now(); + const apiKey = getApiKeyFromRequest(request); + const taskNo = request.nextUrl.searchParams.get('taskNo'); + + // 验证 API Key + const keyRecord = await validateApiKey(apiKey); + if (!keyRecord) { + return errorResponse(API_ERROR.INVALID_API_KEY); + } + + // 验证任务号格式 + if (!taskNo || !validateTaskNo(taskNo)) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'DELETE', + taskNo: taskNo || undefined, + statusCode: 400, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.INVALID_TASK_NO); + } + + try { + // 查询素材 + const material: any = await getMaterialByTaskNo(taskNo); + + if (!material) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'DELETE', + taskNo, + statusCode: 404, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.TASK_NOT_FOUND); + } + + if (material.deletedAt) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'DELETE', + taskNo, + statusCode: 410, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.MATERIAL_DELETED); + } + + // 软删除 + await softDeleteByTaskNo(taskNo); + + // 记录日志 + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'DELETE', + taskNo, + statusCode: 200, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + + return successResponse({ + taskNo, + deletedAt: new Date().toISOString(), + }, '素材已删除'); + } catch (error) { + console.error('[external/product-assets] DELETE error:', error); + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'DELETE', + taskNo, + statusCode: 500, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + return errorResponse(API_ERROR.SERVER_ERROR); + } +} diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts new file mode 100644 index 0000000..e0930ac --- /dev/null +++ b/app/api/materials/[id]/assets/route.ts @@ -0,0 +1,206 @@ +/** + * 素材资产管理接口 + * GET - 获取资产列表 + * POST - 添加资产 + * DELETE - 删除资产 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getAssetsByMaterialId, addAsset, deleteAsset } from '@/lib/product-package'; + +/** + * GET - 获取资产列表 + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const materialId = params.id; + + // 验证素材是否存在 + const material = await prisma.material.findUnique({ + where: { id: materialId, deletedAt: null }, + select: { id: true, userId: true }, + }); + + if (!material) { + return NextResponse.json( + { success: false, error: '素材不存在' }, + { status: 404 } + ); + } + + // 获取资产列表 + const assets = await getAssetsByMaterialId(materialId); + + return NextResponse.json({ + success: true, + data: assets, + }); + } catch (error) { + console.error('[materials/assets] GET error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} + +/** + * POST - 添加资产 + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const materialId = params.id; + const body = await request.json(); + const { assetType, fileUrl, fileName, fileSize, sortOrder } = body; + + // 验证必填字段 + if (!assetType || !fileUrl) { + return NextResponse.json( + { success: false, error: '缺少必填参数' }, + { status: 400 } + ); + } + + // 验证素材是否存在且属于当前用户 + const material = await prisma.material.findUnique({ + where: { id: materialId, deletedAt: null }, + select: { id: true, userId: true }, + }); + + if (!material) { + return NextResponse.json( + { success: false, error: '素材不存在' }, + { status: 404 } + ); + } + + if (material.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: '无权限操作此素材' }, + { status: 403 } + ); + } + + // 添加资产 + const asset = await addAsset({ + materialId, + assetType, + fileUrl, + fileName, + fileSize: fileSize ? BigInt(fileSize) : undefined, + sortOrder, + }); + + return NextResponse.json({ + success: true, + data: asset, + }); + } catch (error) { + console.error('[materials/assets] POST error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} + +/** + * DELETE - 删除资产 + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const materialId = params.id; + const { searchParams } = new URL(request.url); + const assetId = searchParams.get('assetId'); + + if (!assetId) { + return NextResponse.json( + { success: false, error: '缺少 assetId 参数' }, + { status: 400 } + ); + } + + // 验证素材是否存在且属于当前用户 + const material = await prisma.material.findUnique({ + where: { id: materialId, deletedAt: null }, + select: { id: true, userId: true }, + }); + + if (!material) { + return NextResponse.json( + { success: false, error: '素材不存在' }, + { status: 404 } + ); + } + + if (material.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: '无权限操作此素材' }, + { status: 403 } + ); + } + + // 验证资产是否属于该素材 + const asset = await prisma.materialAsset.findUnique({ + where: { id: assetId }, + select: { materialId: true }, + }); + + if (!asset || asset.materialId !== materialId) { + return NextResponse.json( + { success: false, error: '资产不存在或不属于该素材' }, + { status: 404 } + ); + } + + // 删除资产 + await deleteAsset(assetId); + + return NextResponse.json({ + success: true, + message: '删除成功', + }); + } catch (error) { + console.error('[materials/assets] DELETE error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/[id]/assets/sort/route.ts b/app/api/materials/[id]/assets/sort/route.ts new file mode 100644 index 0000000..a790e57 --- /dev/null +++ b/app/api/materials/[id]/assets/sort/route.ts @@ -0,0 +1,89 @@ +/** + * 资产排序接口 + * PUT - 更新资产排序 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { updateAssetSortOrder } from '@/lib/product-package'; + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const materialId = params.id; + const body = await request.json(); + const { assetType, orders } = body; + + // 验证必填字段 + if (!assetType || !orders || !Array.isArray(orders)) { + return NextResponse.json( + { success: false, error: '缺少必填参数或参数格式错误' }, + { status: 400 } + ); + } + + // 验证素材是否存在且属于当前用户 + const material = await prisma.material.findUnique({ + where: { id: materialId, deletedAt: null }, + select: { id: true, userId: true }, + }); + + if (!material) { + return NextResponse.json( + { success: false, error: '素材不存在' }, + { status: 404 } + ); + } + + if (material.userId !== session.user.id) { + return NextResponse.json( + { success: false, error: '无权限操作此素材' }, + { status: 403 } + ); + } + + // 验证所有资产都属于该素材 + const assetIds = orders.map((o: any) => o.assetId); + const assets = await prisma.materialAsset.findMany({ + where: { + id: { in: assetIds }, + materialId, + assetType, + }, + select: { id: true }, + }); + + if (assets.length !== assetIds.length) { + return NextResponse.json( + { success: false, error: '部分资产不存在或不属于该素材' }, + { status: 400 } + ); + } + + // 更新排序 + await updateAssetSortOrder(orders); + + return NextResponse.json({ + success: true, + message: '排序更新成功', + }); + } catch (error) { + console.error('[materials/assets/sort] error:', error); + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 78a7ec4..8795e19 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -11,13 +11,448 @@ import JSZip from 'jszip'; import { canUserDownloadMaterial, isShareLinkAccess, + canUserDownloadSource, } from '@/lib/department-permission'; +import { + DownloadErrorCode, + createDownloadError, + DownloadErrorResponse, +} from '@/lib/download-errors'; function isHttpUrl(url?: string | null): boolean { if (!url || typeof url !== 'string') return false; return /^https?:\/\//i.test(url.trim()); } +// 处理产品全案分类下载 +async function handleProductPackageDownload(material: any, downloadType: string) { + // 确保所有资产数据中的 BigInt 都被转换为字符串,避免序列化错误 + const assets = (material.assets || []).map((asset: any) => ({ + ...asset, + fileSize: asset.fileSize ? (typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() : asset.fileSize) : '0', + })); + + // 添加详细日志 + console.log(`[Download] handleProductPackageDownload: materialId=${material.id}, downloadType=${downloadType}, total assets=${assets.length}`); + console.log(`[Download] Assets breakdown:`, { + main: assets.filter((a: any) => a.assetType === 'main').length, + sku: assets.filter((a: any) => a.assetType === 'sku').length, + detail: assets.filter((a: any) => a.assetType === 'detail').length, + psd: assets.filter((a: any) => a.assetType === 'psd').length, + }); + + // 根据类型筛选资产 + let targetAssets: any[] = []; + let zipName = ''; + + console.log(`[Download] ========== 开始筛选资产 ==========`); + console.log(`[Download] Requested downloadType: ${downloadType}`); + console.log(`[Download] Total assets available: ${assets.length}`); + + switch (downloadType) { + case 'all': + // 全部下载(不含 PSD) + targetAssets = assets.filter((a: any) => { + const isNotPsd = a.assetType !== 'psd'; + if (!isNotPsd) { + console.log(`[Download] Filtering out PSD asset: id=${a.id}, type=${a.assetType}`); + } + return isNotPsd; + }); + zipName = '全部'; + break; + case 'main': + targetAssets = assets.filter((a: any) => { + const isMain = a.assetType === 'main'; + if (!isMain) { + console.log(`[Download] Filtering out non-main asset: id=${a.id}, type=${a.assetType}, expected=main`); + } + return isMain; + }); + zipName = '主图'; + break; + case 'sku': + targetAssets = assets.filter((a: any) => { + const isSku = a.assetType === 'sku'; + if (!isSku) { + console.log(`[Download] Filtering out non-sku asset: id=${a.id}, type=${a.assetType}, expected=sku`); + } + return isSku; + }); + zipName = 'SKU图'; + break; + case 'detail': + targetAssets = assets.filter((a: any) => { + const isDetail = a.assetType === 'detail'; + if (!isDetail) { + console.log(`[Download] Filtering out non-detail asset: id=${a.id}, type=${a.assetType}, expected=detail`); + } + return isDetail; + }); + zipName = '详情图'; + break; + case 'psd': + targetAssets = assets.filter((a: any) => { + const isPsd = a.assetType === 'psd'; + if (!isPsd) { + console.log(`[Download] Filtering out non-psd asset: id=${a.id}, type=${a.assetType}, expected=psd`); + } + return isPsd; + }); + zipName = 'PSD源文件'; + break; + default: + return NextResponse.json( + createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE), + { status: 400 } + ); + } + + console.log(`[Download] Filtered assets for ${downloadType}: count=${targetAssets.length}`); + console.log(`[Download] Selected assets (完整信息):`); + targetAssets.forEach((asset: any, index: number) => { + console.log(` [${index}] { + id: "${asset.id}", + assetType: "${asset.assetType}", + fileUrl: "${asset.fileUrl}", + fileName: "${asset.fileName}", + fileSize: "${asset.fileSize}" + }`); + }); + + // 验证:检查所有资产的fileUrl是否都相同(这可能是问题的根源) + if (targetAssets.length > 1) { + const firstUrl = targetAssets[0]?.fileUrl; + const allSameUrl = targetAssets.every((a: any) => a.fileUrl === firstUrl); + if (allSameUrl) { + console.error(`[Download] ⚠️ CRITICAL ERROR: All ${targetAssets.length} assets have the same fileUrl: ${firstUrl}`); + console.error(`[Download] This means all files are pointing to the same URL, which is likely the root cause of the download issue!`); + console.error(`[Download] Asset details:`); + targetAssets.forEach((asset: any, index: number) => { + console.error(` [${index}] id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); + }); + } else { + console.log(`[Download] ✅ Assets have different fileUrls (this is correct)`); + // 打印每个资产的URL,用于验证 + targetAssets.forEach((asset: any, index: number) => { + console.log(` Asset ${index} URL: ${asset.fileUrl}`); + }); + } + } + + // 验证:检查资产类型是否与请求的类型匹配 + const mismatchedAssets = targetAssets.filter((a: any) => a.assetType !== downloadType); + if (mismatchedAssets.length > 0) { + console.error(`[Download] ⚠️ CRITICAL ERROR: Found ${mismatchedAssets.length} assets with mismatched types!`); + console.error(`[Download] Requested type: ${downloadType}`); + mismatchedAssets.forEach((asset: any) => { + console.error(` Asset id=${asset.id}, expected type=${downloadType}, actual type=${asset.assetType}, url=${asset.fileUrl}`); + }); + } else { + console.log(`[Download] ✅ All ${targetAssets.length} assets have correct type: ${downloadType}`); + } + + console.log(`[Download] ====================================`); + + if (targetAssets.length === 0) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES), + { status: 404 } + ); + } + + // 单个文件直接下载 + if (targetAssets.length === 1 && downloadType === 'psd') { + const asset = targetAssets[0]; + const filePath = asset.fileUrl; + + // PSD 存储在本地 + if (!path.isAbsolute(filePath)) { + const absolutePath = 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(); + }, + }); + + const ext = path.extname(asset.fileName || filePath); + const mimeType = lookup(ext) || 'application/octet-stream'; + + return new NextResponse(stream, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': fileStats.size.toString(), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, + }, + }); + } catch (error) { + console.error('[Download] PSD file not found:', absolutePath, error); + return NextResponse.json( + createDownloadError(DownloadErrorCode.PSD_NOT_FOUND), + { status: 404 } + ); + } + } + } + + // 多个文件打包下载 + const zip = new JSZip(); + let addedCount = 0; + + console.log(`[Download] Starting download for type: ${downloadType}, total assets: ${targetAssets.length}`); + + // 按类型分组资产,如果是全部下载,需要分组到不同文件夹 + const shouldGroupByType = downloadType === 'all'; + const assetsByType: { [key: string]: any[] } = {}; + + if (shouldGroupByType) { + targetAssets.forEach((asset: any) => { + // 确保使用资产的实际类型,而不是从变量名推断 + const type = asset.assetType || 'other'; + if (!assetsByType[type]) { + assetsByType[type] = []; + } + // 验证资产类型是否匹配 + if (asset.assetType && asset.assetType !== type) { + console.error(`[Download] CRITICAL: Asset type mismatch! asset.assetType=${asset.assetType}, inferred type=${type}, assetId=${asset.id}`); + } + assetsByType[type].push(asset); + }); + + console.log(`[Download] Grouped assets by type:`, Object.keys(assetsByType).map(type => `${type}: ${assetsByType[type].length}`).join(', ')); + // 打印每个分组的详细信息 + for (const [type, assets] of Object.entries(assetsByType)) { + console.log(`[Download] Type ${type} assets:`, assets.map((a: any) => ({ id: a.id, url: a.fileUrl, fileName: a.fileName }))); + } + } else { + assetsByType['all'] = targetAssets; + } + + // 类型文件夹名称映射 + const typeFolderMap: { [key: string]: string } = { + main: '主图', + sku: 'SKU图', + detail: '详情图', + psd: 'PSD源文件', + }; + + // 用于跟踪已使用的文件名(完整路径),避免重名覆盖 + const usedFileNames = new Set(); + + // 遍历所有资产 + const processAssets = async (assets: any[], folderPath: string = '') => { + for (let index = 0; index < assets.length; index++) { + const asset = assets[index]; + try { + // 确保使用资产的原始数据,不要被任何变量覆盖 + const fileUrl = asset.fileUrl; + const assetType = asset.assetType; + const assetId = asset.id; + const fileName = asset.fileName; + + // 验证关键字段 + if (!fileUrl) { + console.error(`[Download] CRITICAL: Asset has no fileUrl! assetId=${assetId}, type=${assetType}`); + continue; + } + if (!assetType) { + console.error(`[Download] CRITICAL: Asset has no assetType! assetId=${assetId}, url=${fileUrl}`); + continue; + } + + // 生成唯一文件名(避免重名覆盖) + // 需要检查完整路径(包含文件夹)是否已使用 + const baseFileName = fileName || `${assetType}-${index}.jpg`; + const ext = path.extname(baseFileName) || '.jpg'; + const nameWithoutExt = path.basename(baseFileName, ext); + + // 生成唯一文件名(考虑文件夹路径) + let uniqueFileName = baseFileName; + let suffix = 1; + let fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName; + + while (usedFileNames.has(fullZipPath)) { + uniqueFileName = `${nameWithoutExt}_${suffix}${ext}`; + fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName; + suffix++; + } + + // 标记为已使用 + usedFileNames.add(fullZipPath); + + // 添加详细日志 + console.log(`[Download] ========== Processing asset ==========`); + console.log(`[Download] Asset ID: ${assetId}`); + console.log(`[Download] Asset Type: ${assetType}`); + console.log(`[Download] File URL: ${fileUrl}`); + console.log(`[Download] Original File Name: ${fileName}`); + console.log(`[Download] Unique File Name: ${uniqueFileName}`); + console.log(`[Download] Folder Path: ${folderPath || '(root)'}`); + + // 判断是 S3 URL 还是本地路径 + if (isHttpUrl(fileUrl)) { + // S3 文件 + console.log(`[Download] Detected S3 URL, fetching file...`); + console.log(`[Download] Fetch URL: ${fileUrl}`); + const response = await fetch(fileUrl); + if (!response.ok) { + console.error(`[Download] ❌ Failed to fetch asset: ${fileUrl}, status: ${response.status}, assetType=${assetType}, assetId=${assetId}`); + continue; + } + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 验证文件大小 + if (buffer.length === 0) { + console.error(`[Download] ❌ Empty buffer for asset: ${fileUrl}, assetType=${assetType}, assetId=${assetId}`); + continue; + } + + // 计算文件哈希(前16字节的十六进制),用于验证文件是否相同 + const fileHash = buffer.slice(0, Math.min(16, buffer.length)).toString('hex'); + const fileHashEnd = buffer.slice(Math.max(0, buffer.length - 16)).toString('hex'); + console.log(`[Download] File hash (first 16 bytes): ${fileHash}`); + console.log(`[Download] File hash (last 16 bytes): ${fileHashEnd}`); + + // 使用唯一文件名(fullZipPath 已经在上面计算好了) + const zipPath = fullZipPath; + + console.log(`[Download] Adding to ZIP:`); + console.log(` - ZIP Path: ${zipPath}`); + console.log(` - File Size: ${buffer.length} bytes`); + console.log(` - Asset Type: ${assetType}`); + console.log(` - Asset ID: ${assetId}`); + console.log(` - Source URL: ${fileUrl}`); + console.log(` - File Hash (start): ${fileHash}`); + console.log(` - File Hash (end): ${fileHashEnd}`); + + zip.file(zipPath, buffer); + addedCount++; + console.log(`[Download] ✅ Successfully added S3 file to zip`); + console.log(`[Download] ====================================`); + } else { + // 本地文件 + const absolutePath = path.isAbsolute(fileUrl) + ? fileUrl + : path.join(process.cwd(), fileUrl); + + console.log(`[Download] Reading local file: ${absolutePath} for asset type=${assetType}, id=${assetId}`); + try { + await access(absolutePath); + const fileStream = createReadStream(absolutePath); + const chunks: Buffer[] = []; + + await new Promise((resolve, reject) => { + fileStream.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); + fileStream.on('end', () => resolve()); + fileStream.on('error', reject); + }); + + const buffer = Buffer.concat(chunks); + + // 验证文件大小 + if (buffer.length === 0) { + console.warn(`[Download] Empty buffer for local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`); + continue; + } + + // 使用唯一文件名(fullZipPath 已经在上面计算好了) + const zipPath = fullZipPath; + zip.file(zipPath, buffer); + addedCount++; + console.log(`[Download] ✅ Added local file to zip: ${zipPath} (assetType=${assetType}, assetId=${assetId}, path=${absolutePath}, size=${buffer.length} bytes)`); + } catch (error) { + console.warn(`[Download] Failed to read local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`, error); + continue; + } + } + } catch (error) { + console.error(`[Download] Error adding asset to zip:`, error, asset); + } + } + }; + + // 如果按类型分组,为每种类型创建文件夹 + if (shouldGroupByType) { + console.log(`[Download] ========== Processing grouped assets ==========`); + for (const [type, assets] of Object.entries(assetsByType)) { + const folderName = typeFolderMap[type] || type; + console.log(`[Download] ========== Processing type=${type}, folder=${folderName} ==========`); + console.log(`[Download] Assets count: ${assets.length}`); + + // 验证资产类型是否正确,并打印每个资产的详细信息 + for (const asset of assets) { + console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); + if (asset.assetType !== type) { + console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${type}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`); + } else { + console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`); + } + } + await processAssets(assets, folderName); + console.log(`[Download] ========== Finished processing type=${type} ==========`); + } + } else { + console.log(`[Download] ========== Processing single type=${downloadType} ==========`); + console.log(`[Download] Assets count: ${targetAssets.length}`); + + // 验证资产类型是否正确,并打印每个资产的详细信息 + for (const asset of targetAssets) { + console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); + if (asset.assetType !== downloadType) { + console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${downloadType}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`); + } else { + console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`); + } + } + await processAssets(targetAssets); + console.log(`[Download] ========== Finished processing single type ==========`); + } + + console.log(`[Download] Total files added to zip: ${addedCount}`); + + if (addedCount === 0) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES), + { status: 404 } + ); + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + zipName, + ].filter(Boolean); + + const filename = `${filenameParts.join('_')}.zip`; + + return new NextResponse(zipBuffer as any, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': zipBuffer.length.toString(), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); +} + // 动态打包displayImages为ZIP(用于模特类素材) async function buildDynamicZipFromDisplayImages(material: any) { const displayImages = Array.isArray(material.displayImages) @@ -27,7 +462,7 @@ async function buildDynamicZipFromDisplayImages(material: any) { if (displayImages.length === 0) { console.warn('[Download API] No displayImages available for dynamic zip, materialId:', material.id); return NextResponse.json( - { error: '该素材没有可下载的文件(可能只包含图片)' }, + createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), { status: 404 } ); } @@ -66,7 +501,7 @@ async function buildDynamicZipFromDisplayImages(material: any) { if (addedCount === 0) { console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id); return NextResponse.json( - { error: '该素材没有可下载的文件(可能只包含图片)' }, + createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), { status: 404 } ); } @@ -113,7 +548,10 @@ export async function GET(request: NextRequest) { if (!isShare) { const session = await getServerSession(authOptions); if (!session) { - return NextResponse.json({ error: '请先登录' }, { status: 401 }); + return NextResponse.json( + createDownloadError(DownloadErrorCode.UNAUTHORIZED), + { status: 401 } + ); } const userId = session.user.id; @@ -128,7 +566,7 @@ export async function GET(request: NextRequest) { if (!user || !user.canDownload) { return NextResponse.json( - { error: '您的下载权限已被限制,请联系管理员' }, + createDownloadError(DownloadErrorCode.FORBIDDEN), { status: 403 } ); } @@ -137,12 +575,17 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const materialId = searchParams.get('id'); + const downloadType = searchParams.get('type'); // all/main/sku/detail/psd + const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载) if (!materialId) { - return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); + return NextResponse.json( + createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'), + { status: 400 } + ); } - // 查询素材 + // 查询素材(产品全案需要包含资产信息) let material: any; try { material = await prisma.material.findUnique({ @@ -159,8 +602,124 @@ export async function GET(request: NextRequest) { materialType: true, downloadCount: true, displayImages: true, + // assets: { + // select: { + // id: true, + // assetType: true, + // fileUrl: true, + // fileName: true, + // fileSize: true, + // sortOrder: true, + // }, + // orderBy: { + // sortOrder: 'asc', + // }, + // }, }, }); + + // 如果是产品全案,单独查询 assets + if (material && material.materialType === '产品全案') { + const assets = await prisma.materialAsset.findMany({ + where: { + materialId: material.id, + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { + sortOrder: 'asc', + }, + }); + + // 添加详细日志,验证资产数据 + console.log(`[Download API] ========== 资产查询结果 ==========`); + console.log(`[Download API] MaterialId: ${material.id}`); + console.log(`[Download API] Total assets: ${assets.length}`); + // 将 BigInt 转换为字符串后再序列化 + const assetsForLog = assets.map((a: any) => ({ + ...a, + fileSize: a.fileSize ? a.fileSize.toString() : '0', + })); + console.log(`[Download API] Raw assets from database:`, JSON.stringify(assetsForLog, null, 2)); + + // 按类型分组并打印详细信息 + const assetsByType = { + main: assets.filter((a: any) => a.assetType === 'main'), + sku: assets.filter((a: any) => a.assetType === 'sku'), + detail: assets.filter((a: any) => a.assetType === 'detail'), + psd: assets.filter((a: any) => a.assetType === 'psd'), + }; + + console.log(`[Download API] Assets breakdown:`); + console.log(` - main: ${assetsByType.main.length} assets`); + assetsByType.main.forEach((a: any, i: number) => { + console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); + }); + + console.log(` - sku: ${assetsByType.sku.length} assets`); + assetsByType.sku.forEach((a: any, i: number) => { + console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); + }); + + console.log(` - detail: ${assetsByType.detail.length} assets`); + assetsByType.detail.forEach((a: any, i: number) => { + console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); + }); + + console.log(` - psd: ${assetsByType.psd.length} assets`); + assetsByType.psd.forEach((a: any, i: number) => { + console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); + }); + + // 检查是否有重复的fileUrl + const urlCounts: { [key: string]: { count: number; types: string[]; ids: string[] } } = {}; + assets.forEach((asset: any) => { + if (asset.fileUrl) { + if (!urlCounts[asset.fileUrl]) { + urlCounts[asset.fileUrl] = { count: 0, types: [], ids: [] }; + } + urlCounts[asset.fileUrl].count++; + urlCounts[asset.fileUrl].types.push(asset.assetType); + urlCounts[asset.fileUrl].ids.push(asset.id); + } + }); + + const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1); + if (duplicateUrls.length > 0) { + console.error(`[Download API] ⚠️ CRITICAL: Found duplicate fileUrls!`); + duplicateUrls.forEach(([url, info]) => { + console.error(` URL: ${url}`); + console.error(` Used by ${info.count} assets:`); + console.error(` Types: ${info.types.join(', ')}`); + console.error(` IDs: ${info.ids.join(', ')}`); + }); + } else { + console.log(`[Download API] ✅ No duplicate fileUrls found`); + } + + // 检查是否有资产类型为空或无效 + const invalidAssets = assets.filter((a: any) => !a.assetType || !['main', 'sku', 'detail', 'psd'].includes(a.assetType)); + if (invalidAssets.length > 0) { + console.error(`[Download API] ⚠️ CRITICAL: Found assets with invalid assetType!`); + invalidAssets.forEach((a: any) => { + console.error(` Asset ID: ${a.id}, assetType: ${a.assetType}, url: ${a.fileUrl}`); + }); + } + + console.log(`[Download API] ====================================`); + + // 将 BigInt 转换为字符串,避免序列化错误 + material.assets = assets.map((asset: any) => ({ + ...asset, + fileSize: asset.fileSize ? asset.fileSize.toString() : '0', + })); + } } catch (error: any) { if (error?.meta?.column?.includes('deletedAt')) { console.warn('[Download API] deletedAt field not found, retrying without deletedAt'); @@ -180,13 +739,35 @@ export async function GET(request: NextRequest) { displayImages: true, }, }); + + // 如果是产品全案,单独查询 assets + if (material && material.materialType === '产品全案') { + const assets = await prisma.materialAsset.findMany({ + where: { materialId: material.id }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { + sortOrder: 'asc', + }, + }); + material.assets = assets; + } } else { throw error; } } if (!material) { - return NextResponse.json({ error: '素材不存在' }, { status: 404 }); + return NextResponse.json( + createDownloadError(DownloadErrorCode.MATERIAL_NOT_FOUND), + { status: 404 } + ); } // 权限检查:如果不是分享链接,检查部门级别的下载权限 @@ -211,14 +792,72 @@ export async function GET(request: NextRequest) { if (!canDownload) { return NextResponse.json( - { error: '无权限下载该素材' }, + createDownloadError(DownloadErrorCode.NO_PERMISSION), { status: 403 } ); } + + // 产品全案:PSD下载权限使用 canDownload 字段(与其他素材类型一致) + // 如果用户有下载权限,就可以下载所有内容,包括PSD } } } + // 产品全案:处理分类下载 + if (material.materialType === '产品全案' && downloadType && material.assets) { + // 如果指定了assetId,直接下载单个PSD文件(不打包) + if (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 } + ); + } + + 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(); + }, + }); + + const ext = path.extname(asset.fileName || filePath); + const mimeType = lookup(ext) || 'application/octet-stream'; + + return new NextResponse(stream, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': fileStats.size.toString(), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, + }, + }); + } catch (error) { + console.error('[Download] PSD file not found:', absolutePath, error); + return NextResponse.json( + createDownloadError(DownloadErrorCode.PSD_NOT_FOUND), + { status: 404 } + ); + } + } + + return handleProductPackageDownload(material, downloadType); + } + // 增加下载次数(仅非分享链接时) if (!isShare) { try { @@ -267,7 +906,7 @@ export async function GET(request: NextRequest) { if (!response.ok || !response.body) { console.error('[Download API] Failed to fetch S3 url from zipFileName:', zipUrl, 'status:', response.status); return NextResponse.json( - { error: '文件不存在或无法访问' }, + createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, 'S3文件访问失败'), { status: 404 } ); } @@ -296,7 +935,7 @@ export async function GET(request: NextRequest) { } catch (error) { console.error('[Download API] Error proxying S3 url from zipFileName:', zipUrl, error); return NextResponse.json( - { error: '文件下载失败,请稍后再试' }, + createDownloadError(DownloadErrorCode.S3_ACCESS_FAILED, 'S3代理下载失败'), { status: 500 } ); } @@ -324,7 +963,7 @@ export async function GET(request: NextRequest) { } return NextResponse.json( - { error: '该素材没有可下载的文件(可能只包含图片)' }, + createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), { status: 404 } ); } @@ -371,7 +1010,7 @@ export async function GET(request: NextRequest) { } return NextResponse.json( - { error: '文件不存在,可能已被删除' }, + createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '文件可能已被删除'), { status: 404 } ); } @@ -468,8 +1107,36 @@ export async function GET(request: NextRequest) { }); } catch (error) { console.error('Download error:', error); + + // 根据错误类型返回对应的错误代码 + const errorMessage = error instanceof Error ? error.message : String(error); + + let errorCode = DownloadErrorCode.UNKNOWN_ERROR; + let details: string | undefined; + + if (errorMessage.includes('BigInt')) { + errorCode = DownloadErrorCode.BIGINT_SERIALIZATION_ERROR; + } else if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) { + errorCode = DownloadErrorCode.FILE_NOT_FOUND; + details = errorMessage; + } else if (errorMessage.includes('S3') || errorMessage.includes('存储')) { + errorCode = DownloadErrorCode.S3_ACCESS_FAILED; + details = errorMessage; + } else if (errorMessage.includes('network') || errorMessage.includes('网络')) { + errorCode = DownloadErrorCode.NETWORK_ERROR; + details = errorMessage; + } else if (errorMessage.includes('stream') || errorMessage.includes('流')) { + errorCode = DownloadErrorCode.STREAM_ERROR; + details = errorMessage; + } else if (errorMessage.includes('zip') || errorMessage.includes('ZIP')) { + errorCode = DownloadErrorCode.ZIP_CREATION_FAILED; + details = errorMessage; + } else { + details = errorMessage; + } + return NextResponse.json( - { error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') }, + createDownloadError(errorCode, details), { status: 500 } ); } diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 5f02784..1955725 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; import { deleteFromS3, isS3Url } from '@/lib/s3'; +import { deleteFiles } from '@/lib/file-cleanup'; +import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { createMaterialLog } from '@/lib/material-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; import { @@ -680,7 +683,7 @@ export async function DELETE(request: NextRequest) { const session = await getServerSession(authOptions); // 权限检查 - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '无权限' }, { status: 403 } @@ -714,6 +717,8 @@ export async function DELETE(request: NextRequest) { // 软删除 if (!permanent) { + // 注意:软删除时,文件仍然保留,以便恢复时可以重新使用 + // 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时) await prisma.material.update({ where: { id }, data: { deletedAt: new Date() }, @@ -732,7 +737,7 @@ export async function DELETE(request: NextRequest) { }, }); - return NextResponse.json({ message: '删除成功(可恢复)' }); + return NextResponse.json({ message: '删除成功(可恢复),文件已保留' }); } // 永久删除 @@ -750,72 +755,32 @@ export async function DELETE(request: NextRequest) { }, }); - // 收集所有文件路径 - const filesToDelete: string[] = []; + // 使用统一的素材文件收集函数(根据素材类型自动收集所有文件) + const fileInfo = await collectMaterialFiles(id, material); + const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; - if ((material as any).zipFilePath) { - filesToDelete.push((material as any).zipFilePath); + console.log(`[Delete] Material ${id} (${(material as any).materialType}) file collection:`, { + fileUrls: fileInfo.fileUrls.length, + directories: fileInfo.directories.length, + relatedRecords: fileInfo.relatedRecords, + }); + + // 使用统一的文件删除工具批量删除 + const result = await deleteFiles(filesToDelete); + const deletedCount = result.deletedCount; + const failedCount = result.failedCount; + + if (result.errors.length > 0) { + console.error(`[Delete] Material ${id} files cleanup errors:`, result.errors); } - - if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath) { - filesToDelete.push((material as any).videoSourceUrl); - } - - if ((material as any).previewUrl) { - filesToDelete.push((material as any).previewUrl); - } - - if ((material as any).videoPreview540Url) { - filesToDelete.push((material as any).videoPreview540Url); - } - - if ((material as any).videoPreviewClipUrl) { - filesToDelete.push((material as any).videoPreviewClipUrl); - } - - if ((material as any).videoCoverUrl) { - filesToDelete.push((material as any).videoCoverUrl); - } - - if (Array.isArray((material as any).displayImages) && (material as any).displayImages.length > 0) { - filesToDelete.push(...(material as any).displayImages.filter((img: string) => img && img.trim())); - } - - // 去重 - const uniqueFiles = Array.from(new Set(filesToDelete.filter(f => f && f.trim()))); - - // 删除文件 - let deletedCount = 0; - let failedCount = 0; - - for (const filePath of uniqueFiles) { - try { - if (isS3Url(filePath)) { - await deleteFromS3(filePath); - deletedCount++; - console.log(`[Delete] Deleted S3 file: ${filePath}`); - } else { - // 本地文件 - const { unlink } = await import('fs/promises'); - try { - await unlink(filePath); - deletedCount++; - console.log(`[Delete] Deleted local file: ${filePath}`); - } catch (err: any) { - if (err.code !== 'ENOENT') { - console.error(`[Delete] Failed to delete local file: ${filePath}`, err); - failedCount++; - } - } - } - } catch (err) { - console.error(`[Delete] Failed to delete file: ${filePath}`, err); - failedCount++; - } - } - console.log(`[Delete] Material ${id} files cleanup: ${deletedCount} deleted, ${failedCount} failed`); + // 删除关联记录(MaterialAsset, MaterialSku 等) + const relatedResult = await deleteMaterialRelatedRecords(id, (material as any).materialType); + if (relatedResult.deletedAssets !== undefined || relatedResult.deletedSkus !== undefined) { + console.log(`[Delete] Material ${id} related records deleted:`, relatedResult); + } + // 从数据库删除 await prisma.material.delete({ where: { id }, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index e1f8c66..60e5178 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -65,6 +65,9 @@ export async function GET(request: NextRequest) { videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, + taskNo: true, + taskStatus: true, + taskFetchedAt: true, } as any, }); @@ -72,6 +75,62 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } + // 如果是产品全案,查询资产列表 + let assets: any[] = []; + if ((material.materialType as unknown as string) === '产品全案') { + const rawAssets = await prisma.materialAsset.findMany({ + where: { + materialId: id, + }, + orderBy: { sortOrder: 'asc' }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + }); + + // 添加详细日志 + console.log(`[Share API] ========== 查询资产 ==========`); + console.log(`[Share API] MaterialId: ${id}`); + 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}`); + }); + + // 检查是否有重复的fileUrl + const urlCounts: { [key: string]: { count: number; types: string[] } } = {}; + rawAssets.forEach((asset: any) => { + if (asset.fileUrl) { + if (!urlCounts[asset.fileUrl]) { + urlCounts[asset.fileUrl] = { count: 0, types: [] }; + } + urlCounts[asset.fileUrl].count++; + urlCounts[asset.fileUrl].types.push(asset.assetType); + } + }); + + const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1); + if (duplicateUrls.length > 0) { + console.error(`[Share API] ⚠️ WARNING: Found duplicate fileUrls!`); + duplicateUrls.forEach(([url, info]) => { + console.error(` URL: ${url}`); + console.error(` Used by ${info.count} assets with types: ${info.types.join(', ')}`); + }); + } + + console.log(`[Share API] ====================================`); + + // 将 BigInt 转换为字符串 + assets = rawAssets.map(asset => ({ + ...asset, + fileSize: asset.fileSize.toString(), + })); + } + // 如果用户已登录,检查点赞和收藏状态 let isLiked = false; let isFavorited = false; @@ -102,6 +161,7 @@ export async function GET(request: NextRequest) { displayImages: parseDisplayImages(material.displayImages || null), isLiked, isFavorited, + assets: (material.materialType as unknown as string) === '产品全案' ? assets : undefined, }; return NextResponse.json(response); diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index a521928..dcfd910 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -55,16 +55,17 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } - // 检查权限:素材所有者、普通管理员或超级管理员都可以编辑 + // 检查权限:素材所有者、系统管理员或超级管理员都可以编辑 const user = session.user as any; const userId = user.id; const userRole = user.role; - // 检查是否是超级管理员 - const { isSuperAdmin } = await import('@/lib/department-permission'); + // 检查是否是超级管理员或系统管理员 + const { isSuperAdmin, isSystemAdmin } = await import('@/lib/department-permission'); const isUserSuperAdmin = await isSuperAdmin(userId); + const isUserSystemAdmin = isSystemAdmin(userRole); - if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) { + if (material.userId !== userId && !isUserSystemAdmin && !isUserSuperAdmin) { return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 }); } diff --git a/app/api/materials/upload/cleanup/route.ts b/app/api/materials/upload/cleanup/route.ts index 624fdf8..08b0d4d 100644 --- a/app/api/materials/upload/cleanup/route.ts +++ b/app/api/materials/upload/cleanup/route.ts @@ -33,28 +33,36 @@ async function cleanupUploadSession(uploadId: string, uploadDir: string): Promis return { success: true, deletedFiles: 0, freedSpace: 0 } } - // 读取目录中的所有文件 - const files = await fs.readdir(tempDir) - - for (const file of files) { - const filePath = path.join(tempDir, file) - try { - const stat = await fs.stat(filePath) - if (stat.isFile()) { - await fs.unlink(filePath) - deletedFiles++ - freedSpace += stat.size + // 先计算目录大小(用于统计释放空间) + try { + const files = await fs.readdir(tempDir) + for (const file of files) { + const filePath = path.join(tempDir, file) + try { + const stat = await fs.stat(filePath) + if (stat.isFile()) { + freedSpace += stat.size + deletedFiles++ // 统计文件数量 + } + } catch { + // 忽略单个文件的错误 } - } catch (err) { - console.warn(`[Cleanup] 删除文件失败: ${filePath}`, err) } + } catch { + // 忽略读取目录的错误 } - // 删除目录本身 - try { - await fs.rmdir(tempDir) - } catch (err) { - console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, err) + // 使用统一的文件删除工具递归删除整个目录 + const { deleteLocalFile } = await import('@/lib/file-cleanup') + const result = await deleteLocalFile(tempDir) + + if (result.success) { + // 如果之前没有统计到文件,至少算删除了目录本身 + if (deletedFiles === 0) { + deletedFiles = 1 + } + } else { + console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, result.error) } return { success: true, deletedFiles, freedSpace } diff --git a/app/api/materials/upload/complete/route.ts b/app/api/materials/upload/complete/route.ts index fc0af0f..6f4a88b 100644 --- a/app/api/materials/upload/complete/route.ts +++ b/app/api/materials/upload/complete/route.ts @@ -87,15 +87,12 @@ export async function POST(req: NextRequest) { } // 清理分片,保留合并后的文件供后续正式上传使用 - await Promise.all( - chunkFiles.map(async (file) => { - try { - await fs.unlink(file) - } catch (err) { - console.warn('[UploadComplete] 清理分片失败(忽略):', err) - } - }) - ) + // 使用统一的文件删除工具 + const { deleteFiles } = await import('@/lib/file-cleanup') + const result = await deleteFiles(chunkFiles) + if (result.failedCount > 0) { + console.warn('[UploadComplete] 清理分片失败:', result.errors) + } // 不立刻删除会话,方便后续调试。交由调用方完成正式入库后再清理。 // 返回合并结果 diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts new file mode 100644 index 0000000..eb3c757 --- /dev/null +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -0,0 +1,258 @@ +/** + * 产品全案上传完成接口 + * POST - 保存资产信息,更新任务状态 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { addAssets, updateTaskStatus, TASK_STATUS, ASSET_TYPE, cleanupProductPackageFiles } from '@/lib/product-package'; + +interface AssetData { + fileName: string; + fileSize: number; + fileUrl: string; + sortOrder?: number; +} + +interface CompleteRequestBody { + materialId: string; + assets: { + main?: AssetData[]; + sku?: AssetData[]; + detail?: AssetData[]; + psd?: AssetData; + }; +} + +export async function POST(request: NextRequest) { + let materialId: string | null = null; + let uploadedFileUrls: string[] = []; + + try { + // 验证用户登录 + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const body: CompleteRequestBody = await request.json(); + const { materialId: bodyMaterialId, assets } = body; + + // 验证必填字段 + if (!bodyMaterialId || !assets) { + return NextResponse.json( + { success: false, error: '缺少必填参数' }, + { status: 400 } + ); + } + + materialId = bodyMaterialId; + + // 收集所有已上传的文件 URL(用于失败时清理) + if (assets.main) uploadedFileUrls.push(...assets.main.map(a => a.fileUrl)); + if (assets.sku) uploadedFileUrls.push(...assets.sku.map(a => a.fileUrl)); + if (assets.detail) uploadedFileUrls.push(...assets.detail.map(a => a.fileUrl)); + if (assets.psd) uploadedFileUrls.push(assets.psd.fileUrl); + + // 验证素材是否存在且属于当前用户 + 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.id) { + return NextResponse.json( + { success: false, error: '无权限操作此素材' }, + { status: 403 } + ); + } + + // 准备资产数据 + const assetRecords: Array<{ + materialId: string; + assetType: any; // 使用 any 避免类型错误 + fileUrl: string; + fileName?: string; + fileSize?: bigint; + sortOrder?: number; + }> = []; + + // 主图 + if (assets.main && assets.main.length > 0) { + assets.main.forEach((asset, index) => { + assetRecords.push({ + materialId: materialId!, + assetType: ASSET_TYPE.MAIN, + fileUrl: asset.fileUrl, + fileName: asset.fileName, + fileSize: BigInt(asset.fileSize), + sortOrder: index, + }); + }); + } + + // SKU图 + if (assets.sku && assets.sku.length > 0) { + assets.sku.forEach((asset, index) => { + assetRecords.push({ + materialId: materialId!, + assetType: ASSET_TYPE.SKU, + fileUrl: asset.fileUrl, + fileName: asset.fileName, + fileSize: BigInt(asset.fileSize), + sortOrder: index, + }); + }); + } + + // 详情图 + if (assets.detail && assets.detail.length > 0) { + assets.detail.forEach((asset) => { + assetRecords.push({ + materialId: materialId!, + assetType: ASSET_TYPE.DETAIL, + fileUrl: asset.fileUrl, + fileName: asset.fileName, + fileSize: BigInt(asset.fileSize), + sortOrder: asset.sortOrder || 0, + }); + }); + } + + // PSD源文件(支持多个) + if (assets.psd && Array.isArray(assets.psd) && assets.psd.length > 0) { + assets.psd.forEach((psdAsset, index) => { + assetRecords.push({ + materialId: materialId!, + assetType: ASSET_TYPE.PSD, + fileUrl: psdAsset.fileUrl, + fileName: psdAsset.fileName, + fileSize: BigInt(psdAsset.fileSize), + sortOrder: index, + }); + }); + } else if (assets.psd && !Array.isArray(assets.psd)) { + // 兼容旧格式(单个PSD文件) + assetRecords.push({ + materialId: materialId!, + assetType: ASSET_TYPE.PSD, + fileUrl: assets.psd.fileUrl, + fileName: assets.psd.fileName, + fileSize: BigInt(assets.psd.fileSize), + sortOrder: 0, + }); + } + + // 保存资产 + if (assetRecords.length > 0) { + // 添加详细日志,验证保存前的数据 + console.log(`[Upload Complete] ========== 准备保存资产 ==========`); + console.log(`[Upload Complete] MaterialId: ${materialId}`); + console.log(`[Upload Complete] Total asset records: ${assetRecords.length}`); + assetRecords.forEach((record, index) => { + console.log(`[Upload Complete] Asset ${index}: type=${record.assetType}, url=${record.fileUrl}, fileName=${record.fileName}`); + }); + + // 检查是否有重复的fileUrl + const urlCounts: { [key: string]: { count: number; types: string[] } } = {}; + assetRecords.forEach((record) => { + if (record.fileUrl) { + if (!urlCounts[record.fileUrl]) { + urlCounts[record.fileUrl] = { count: 0, types: [] }; + } + urlCounts[record.fileUrl].count++; + urlCounts[record.fileUrl].types.push(record.assetType); + } + }); + + const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1); + if (duplicateUrls.length > 0) { + console.error(`[Upload Complete] ⚠️ WARNING: Found duplicate fileUrls before saving!`); + duplicateUrls.forEach(([url, info]) => { + console.error(` URL: ${url}`); + console.error(` Used by ${info.count} assets with types: ${info.types.join(', ')}`); + }); + } + + console.log(`[Upload Complete] ====================================`); + + await addAssets(assetRecords); + + console.log(`[Upload Complete] ✅ Assets saved successfully`); + } + + // 获取封面图(第一张主图或第一张SKU图) + let previewUrl = ''; + if (assets.main && assets.main.length > 0) { + previewUrl = assets.main[0].fileUrl; + } else if (assets.sku && assets.sku.length > 0) { + previewUrl = assets.sku[0].fileUrl; + } + + // 更新素材状态和封面图 + await prisma.material.update({ + where: { id: materialId! }, + data: { + previewUrl, + taskStatus: TASK_STATUS.COMPLETED, + } as any, + }); + + // 记录日志 + await prisma.materialLog.create({ + data: { + materialId: materialId!, + userId: session.user.id, + action: 'UPLOAD', + changes: JSON.stringify({ + taskNo: material.taskNo, + assetCounts: { + main: assets.main?.length || 0, + sku: assets.sku?.length || 0, + detail: assets.detail?.length || 0, + psd: assets.psd ? 1 : 0, + }, + }), + }, + }); + + return NextResponse.json({ + success: true, + data: { + materialId: materialId!, + taskNo: material.taskNo, + message: '上传完成', + }, + }); + } catch (error) { + console.error('[product-package/complete] error:', error); + + // 如果失败,尝试清理已上传的文件,避免产生垃圾文件 + if (materialId) { + console.log(`[product-package/complete] Attempting to cleanup files for failed upload: ${materialId}`); + try { + const cleanupResult = await cleanupProductPackageFiles(materialId, uploadedFileUrls); + console.log(`[product-package/complete] Cleanup result:`, cleanupResult); + } catch (cleanupError) { + console.error('[product-package/complete] Cleanup failed:', cleanupError); + // 清理失败不影响错误返回 + } + } + + return NextResponse.json( + { success: false, error: '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts new file mode 100644 index 0000000..bf9145f --- /dev/null +++ b/app/api/materials/upload/product-package/init/route.ts @@ -0,0 +1,125 @@ +/** + * 产品全案上传初始化接口 + * POST - 创建素材记录,返回上传配置 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { validateTaskNo, isTaskNoExists, TASK_STATUS } from '@/lib/product-package'; +import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; +import { deleteFiles } from '@/lib/file-cleanup'; + +export async function POST(request: NextRequest) { + try { + // 验证用户登录 + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '未登录' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { taskNo, productModel, categoryName, sceneName } = body; + + // 验证必填字段 + if (!taskNo || !productModel || !categoryName) { + return NextResponse.json( + { success: false, error: '任务号、SPU型号、店铺为必填项' }, + { status: 400 } + ); + } + + // 验证任务号格式 + if (!validateTaskNo(taskNo)) { + return NextResponse.json( + { success: false, error: '任务号格式错误,应为 TASK + 12位数字' }, + { status: 400 } + ); + } + + // 检查任务号是否已存在(未删除的素材) + const exists = await isTaskNoExists(taskNo); + if (exists) { + return NextResponse.json( + { success: false, error: '任务号已存在' }, + { status: 409 } + ); + } + + // 检查是否有已删除的素材使用该任务号,如果有则永久删除 + const deletedMaterial = await prisma.material.findFirst({ + where: { + taskNo, + deletedAt: { not: null }, // 只查找已删除的素材 + }, + }); + + if (deletedMaterial) { + console.log(`[Init] 发现已删除的素材使用任务号 ${taskNo},正在永久删除旧素材...`); + + try { + // 收集旧素材的所有文件 + const fileInfo = await collectMaterialFiles(deletedMaterial.id, deletedMaterial); + const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; + + // 删除文件 + if (filesToDelete.length > 0) { + const result = await deleteFiles(filesToDelete); + console.log(`[Init] 已删除旧素材文件: ${result.deletedCount} 个,失败: ${result.failedCount} 个`); + } + + // 删除关联记录 + await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType); + + // 从数据库永久删除 + await prisma.material.delete({ + where: { id: deletedMaterial.id }, + }); + + console.log(`[Init] 已永久删除旧素材 ${deletedMaterial.id},任务号 ${taskNo} 现在可以重新使用`); + } catch (cleanupError) { + console.error(`[Init] 清理旧素材失败:`, cleanupError); + // 即使清理失败,也尝试创建新素材(数据库唯一约束可能会阻止) + } + } + + // 创建素材记录 + const material: any = await prisma.material.create({ + data: { + taskNo, + productModel, + categoryName, + sceneName: sceneName || '', + materialType: '产品全案', + taskStatus: TASK_STATUS.UPLOADING, + previewUrl: '', // 临时占位,上传完成后更新 + authorName: (session.user as any).username || (session.user as any).realName || session.user.email, + userId: session.user.id, + renderer: '', // 产品全案不需要渲染器 + } as any, + }); + + // 返回素材ID和上传配置 + return NextResponse.json({ + success: true, + data: { + materialId: material.id, + taskNo: material.taskNo, + message: '初始化成功,可以开始上传文件', + }, + }); + } catch (error) { + console.error('[product-package/init] error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('[product-package/init] error details:', { errorMessage, errorStack }); + return NextResponse.json( + { success: false, error: errorMessage || '服务器错误' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index ced0dec..124110a 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -323,15 +323,22 @@ export async function POST(request: NextRequest) { // 如果是复用服务器合并文件,用后清理以节省空间 if (sourcePath) { try { - await fs.unlink(sourcePath); - console.log('[Upload] 已清理临时合并文件:', sourcePath); + // 使用统一的文件删除工具 + const { deleteLocalFile } = await import('@/lib/file-cleanup'); + const result = await deleteLocalFile(sourcePath); + if (result.success) { + console.log('[Upload] 已清理临时合并文件:', sourcePath); + } else { + console.warn('[Upload] 清理临时合并文件失败:', sourcePath, result.error); + } // 同时清理整个临时目录(如果为空) const tempDir = path.dirname(sourcePath); try { const files = await fs.readdir(tempDir); if (files.length === 0) { - await fs.rmdir(tempDir); + const { deleteLocalFile } = await import('@/lib/file-cleanup'); + await deleteLocalFile(tempDir); console.log('[Upload] 已清理空临时目录:', tempDir); } } catch (dirError) { @@ -451,8 +458,13 @@ export async function POST(request: NextRequest) { // 尝试清理源视频文件,避免长时间占用磁盘空间(垃圾文件) if (zipFilePath) { try { - await fs.unlink(zipFilePath); - console.log('[Upload] 🧹 Removed source video file after enqueue failure:', zipFilePath); + const { deleteLocalFile } = await import('@/lib/file-cleanup'); + const result = await deleteLocalFile(zipFilePath); + if (result.success) { + console.log('[Upload] 🧹 Removed source video file after enqueue failure:', zipFilePath); + } else { + console.warn('[Upload] ⚠️ Failed to remove source video file:', zipFilePath, result.error); + } } catch (removeError) { console.warn('[Upload] ⚠️ Failed to remove source video file after enqueue failure (ignored):', { path: zipFilePath, diff --git a/app/api/platforms/route.ts b/app/api/platforms/route.ts index 0c73c9f..a04a3f4 100644 --- a/app/api/platforms/route.ts +++ b/app/api/platforms/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; // GET - 获取所有平台分类 export async function GET(request: NextRequest) { @@ -55,7 +56,7 @@ export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '需要管理员权限' }, { status: 403 } @@ -102,7 +103,7 @@ export async function PUT(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '需要管理员权限' }, { status: 403 } @@ -172,7 +173,7 @@ export async function DELETE(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '需要管理员权限' }, { status: 403 } diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 2ede850..5079559 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { isSystemAdmin } from '@/lib/department-permission'; import { proxyHttpUrl } from '@/lib/url-rewrite'; // GET - 获取系统设置 @@ -43,7 +44,7 @@ export async function GET() { export async function PUT(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session || session.user.role !== 'ADMIN') { + if (!session || !isSystemAdmin(session.user.role)) { return NextResponse.json( { error: '无权限' }, { status: 403 } diff --git a/app/api/upload/psd/route.ts b/app/api/upload/psd/route.ts new file mode 100644 index 0000000..596c7e9 --- /dev/null +++ b/app/api/upload/psd/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { writeFile, mkdir } from 'fs/promises'; +import path from 'path'; + +// POST - 上传 PSD 文件到本地 +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 materialId = formData.get('materialId') as string; + + if (!file || !materialId) { + return NextResponse.json({ error: '缺少文件或素材ID' }, { status: 400 }); + } + + // 生成本地存储路径 + const timestamp = Date.now(); + const ext = file.name.split('.').pop(); + const fileName = `psd-${timestamp}.${ext}`; + const uploadDir = path.join(process.cwd(), 'uploads', 'product-package', materialId, 'psd'); + const filePath = path.join(uploadDir, fileName); + + // 确保目录存在 + await mkdir(uploadDir, { recursive: true }); + + // 保存文件 + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(filePath, buffer); + + // 返回相对路径 + const relativePath = path.join('uploads', 'product-package', materialId, 'psd', fileName); + + return NextResponse.json({ path: relativePath }); + } catch (error) { + console.error('[PSD Upload] Error:', error); + return NextResponse.json( + { error: '上传失败: ' + (error instanceof Error ? error.message : '未知错误') }, + { status: 500 } + ); + } +} diff --git a/app/api/upload/s3/route.ts b/app/api/upload/s3/route.ts new file mode 100644 index 0000000..d6622c3 --- /dev/null +++ b/app/api/upload/s3/route.ts @@ -0,0 +1,59 @@ +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; // main/sku/detail + + if (!file) { + return NextResponse.json({ error: '缺少文件' }, { status: 400 }); + } + + // 生成文件路径(使用更精确的时间戳和随机数,避免文件名冲突) + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 9); // 7位随机字符串 + const ext = file.name.split('.').pop(); + const fileName = `${type}-${timestamp}-${randomSuffix}.${ext}`; + // 注意:s3Path 不应该包含 S3_FILE_PATH 前缀,因为 uploadToS3 会自动添加 + const s3Path = `product-package/${fileName}`; + + // 上传到 S3 + const buffer = Buffer.from(await file.arrayBuffer()); + + // 添加文件内容验证日志(计算文件哈希,用于验证文件是否不同) + const fileHashStart = buffer.slice(0, Math.min(16, buffer.length)).toString('hex'); + const fileHashEnd = buffer.slice(Math.max(0, buffer.length - 16)).toString('hex'); + const fileSize = buffer.length; + console.log(`[S3 Upload] ========== Uploading file ==========`); + console.log(`[S3 Upload] Type: ${type}`); + console.log(`[S3 Upload] Original fileName: ${file.name}`); + console.log(`[S3 Upload] Generated fileName: ${fileName}`); + console.log(`[S3 Upload] File size: ${fileSize} bytes`); + console.log(`[S3 Upload] File hash (first 16 bytes): ${fileHashStart}`); + console.log(`[S3 Upload] File hash (last 16 bytes): ${fileHashEnd}`); + console.log(`[S3 Upload] S3 path: ${s3Path}`); + + const url = await uploadToS3(buffer, s3Path, file.type); + + console.log(`[S3 Upload] Uploaded successfully: url=${url}`); + console.log(`[S3 Upload] ====================================`); + + return NextResponse.json({ url }); + } catch (error) { + console.error('[S3 Upload] Error:', error); + return NextResponse.json( + { error: '上传失败: ' + (error instanceof Error ? error.message : '未知错误') }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 46c1773..2aa22e1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -39,6 +39,15 @@ export default function RootLayout({ }) { return ( + + {/* 禁用浏览器缓存,确保用户总是获取最新代码 */} + + + + {/* 允许 blob URL 在 HTTP 页面上使用(解决 HTTPS 警告) */} + {/* 注意:frame-ancestors 只能通过 HTTP 响应头设置,不能通过 meta 标签 */} + + {children} diff --git a/app/page.tsx b/app/page.tsx index 5713119..4cc9cf5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,6 +10,7 @@ import AIChatWidget from '@/components/AIChatWidget' import VideoPlayerModal from '@/components/VideoPlayerModal' import ToastBubble from '@/components/ToastBubble' import { useToast } from '@/hooks/useToast' +import { parseDownloadError } from '@/lib/download-errors' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' @@ -333,17 +334,27 @@ export default function Home() { setCurrentPage(cache.currentPage || 1) } + // 立即清除loading状态(使用缓存时不需要加载) + setLoading(false) + // 恢复滚动位置(延迟执行,确保内容已渲染) if (savedScrollPosition) { - setTimeout(() => { - window.scrollTo({ - top: parseInt(savedScrollPosition, 10), - behavior: 'auto' + // 使用requestAnimationFrame确保DOM已完全渲染 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo({ + top: parseInt(savedScrollPosition, 10), + behavior: 'auto' + }) + // 清除已恢复的缓存 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') }) - // 清除已恢复的缓存 - sessionStorage.removeItem('home_scroll_position') - sessionStorage.removeItem('home_materials_cache') - }, 100) + }) + } else { + // 没有滚动位置,直接清除缓存 + sessionStorage.removeItem('home_scroll_position') + sessionStorage.removeItem('home_materials_cache') } return // 使用缓存,不重新获取数据 @@ -354,13 +365,15 @@ export default function Home() { } } else if (savedScrollPosition) { // 只有滚动位置,没有缓存数据,恢复滚动位置 - setTimeout(() => { - window.scrollTo({ - top: parseInt(savedScrollPosition, 10), - behavior: 'auto' + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo({ + top: parseInt(savedScrollPosition, 10), + behavior: 'auto' + }) + sessionStorage.removeItem('home_scroll_position') }) - sessionStorage.removeItem('home_scroll_position') - }, 100) + }) } } catch (e) { console.warn('[Home] Failed to restore state from sessionStorage:', e) @@ -1012,15 +1025,18 @@ export default function Home() { )) } catch (error) { console.error('下载处理失败:', error) - showToast('下载失败,请稍后重试', { variant: 'error' }) + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) } } else { try { const errorText = xhr.responseText - const error = JSON.parse(errorText) - showToast(error.error || '下载失败', { variant: 'error' }) + const error = errorText ? JSON.parse(errorText) : { error: '下载失败' } + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) } catch { - showToast('下载失败', { variant: 'error' }) + const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' }) + showToast(message, { variant: 'error' }) } } setDownloadingId(null) @@ -1028,7 +1044,8 @@ export default function Home() { }) xhr.addEventListener('error', () => { - showToast('下载失败,请稍后重试', { variant: 'error' }) + const { message } = parseDownloadError({ code: 'NETWORK_ERROR' }) + showToast(message, { variant: 'error' }) setDownloadingId(null) setDownloadProgress(0) }) diff --git a/app/register/page.tsx b/app/register/page.tsx index 0281205..4141b69 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -4,13 +4,14 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import Image from 'next/image' import Link from 'next/link' +import DepartmentTreeSelect from '@/components/DepartmentTreeSelect' export default function RegisterPage() { const router = useRouter() const [email, setEmail] = useState('') const [username, setUsername] = useState('') const [department, setDepartment] = useState('') - const [departments, setDepartments] = useState>([]) + const [departments, setDepartments] = useState>([]) const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [loading, setLoading] = useState(false) @@ -132,18 +133,19 @@ export default function RegisterPage() { - + onChange={(deptId) => { + // 将部门ID转换为部门名称(API接受名称) + const dept = departments.find(d => d.id === deptId || d.name === deptId) + setDepartment(dept ? dept.name : '') + }} + placeholder="请选择部门" + allowEmpty={true} + emptyLabel="未分配" + className="w-full" + /> {departments.length === 0 && (

暂无可选部门,请联系管理员添加 diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index a45ee43..1660496 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -12,6 +12,8 @@ import SkuEditModal from '@/components/SkuEditModal' import ToastBubble from '@/components/ToastBubble' import { useToast } from '@/hooks/useToast' import AudioVisualizer from '@/components/AudioVisualizer' +import ProductPackageDetail from '@/components/detail/ProductPackageDetail' +import { parseDownloadError } from '@/lib/download-errors' interface Material { id: string @@ -38,6 +40,17 @@ interface Material { videoProcessingStatus?: string | null // 视频处理状态 isLiked?: boolean // 是否已点赞 isFavorited?: boolean // 是否已收藏 + taskNo?: string // 产品全案任务号 + taskStatus?: string // 产品全案任务状态 + taskFetchedAt?: string // 产品全案被获取时间 + assets?: Array<{ + id: string + assetType: string + fileUrl: string + fileName: string + fileSize: number + sortOrder: number + }> // 产品全案资产列表 skus?: Array<{ id: string skuName: string @@ -269,7 +282,8 @@ export default function SharePage() { if (!response.ok) { const error = await response.json() - showToast(error.error || '批量下载失败', { variant: 'error' }) + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) setDownloading(false) setDownloadProgress(0) return @@ -294,14 +308,18 @@ export default function SharePage() { a.download = filename document.body.appendChild(a) a.click() - a.remove() - window.URL.revokeObjectURL(url) + // 延迟清理 blob URL,确保下载已经开始 + setTimeout(() => { + a.remove() + window.URL.revokeObjectURL(url) + }, 100) showToast(`成功下载 ${selectedSkuIds.size} 个SKU`, { variant: 'success' }) setSelectedSkuIds(new Set()) // 清空选择 } catch (error) { console.error('批量下载 SKU 失败:', error) - showToast('批量下载失败,请稍后重试', { variant: 'error' }) + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) } finally { setDownloading(false) setDownloadProgress(0) @@ -351,21 +369,34 @@ export default function SharePage() { a.download = filename document.body.appendChild(a) a.click() - a.remove() - window.URL.revokeObjectURL(url) + // 延迟清理 blob URL,确保下载已经开始 + setTimeout(() => { + a.remove() + window.URL.revokeObjectURL(url) + }, 100) } catch (error) { console.error('下载处理失败:', error) - showToast('下载失败,请稍后重试', { variant: 'error' }) + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) } } else { - showToast('下载失败', { variant: 'error' }) + try { + const errorText = xhr.responseText + const errorData = errorText ? JSON.parse(errorText) : { error: '下载失败' } + const { message } = parseDownloadError(errorData) + showToast(message, { variant: 'error' }) + } catch { + const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' }) + showToast(message, { variant: 'error' }) + } } setDownloading(false) setDownloadProgress(0) }) xhr.addEventListener('error', () => { - showToast('下载失败,请稍后重试', { variant: 'error' }) + const { message } = parseDownloadError({ code: 'NETWORK_ERROR' }) + showToast(message, { variant: 'error' }) setDownloading(false) setDownloadProgress(0) }) @@ -404,6 +435,89 @@ export default function SharePage() {

+ {/* 产品全案类型:使用专用详情组件 */} + {material.materialType === '产品全案' ? ( +
+ { + // 处理下载 + let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`; + if (assetId) { + downloadUrl += `&assetId=${assetId}`; + } + // 使用 fetch 下载,这样可以监听下载完成并重置状态 + 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; + + // 根据类型和assetId生成文件名 + if (assetId && type === 'psd') { + // 单个PSD文件下载,从响应头获取文件名 + 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 = 'source.psd'; + } + } else { + a.download = 'source.psd'; + } + } else { + // 批量下载,使用ZIP格式 + const typeNames: Record = { + all: '全部', + main: '主图', + sku: 'SKU图', + detail: '详情图', + psd: 'PSD源文件', + }; + a.download = `${material.productModel}_${typeNames[type] || type}.zip`; + } + + document.body.appendChild(a); + a.click(); + // 延迟清理 blob URL,确保下载已经开始 + 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()} + /> +
+ ) : ( + /* 其他素材类型:使用原有左右布局 */ + <> {/* 所有素材类型统一使用左右布局 */}
{/* 左侧:预览图(固定790px宽度) */} @@ -695,6 +809,8 @@ export default function SharePage() {
+ + )} {/* 返回顶部按钮 */} diff --git a/components/DepartmentPermissionsTab.tsx b/components/DepartmentPermissionsTab.tsx index 9cbe594..2f3ae5d 100644 --- a/components/DepartmentPermissionsTab.tsx +++ b/components/DepartmentPermissionsTab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' interface DepartmentPermission { department: string @@ -11,9 +11,17 @@ interface DepartmentPermission { linkedToDepartment: string | null } +interface Department { + id: string + name: string + parentId?: string | null + code?: string | null + sortOrder?: number +} + interface DepartmentPermissionsTabProps { showToast: (message: string, type?: 'success' | 'error') => void - departments: Array<{ id: string; name: string }> + departments: Array<{ id: string; name: string; parentId?: string | null; code?: string | null; sortOrder?: number }> } export default function DepartmentPermissionsTab({ @@ -27,8 +35,47 @@ export default function DepartmentPermissionsTab({ const [editedPermissions, setEditedPermissions] = useState>>({}) const [dirtyDepartments, setDirtyDepartments] = useState>(new Set()) const [linkedDepartments, setLinkedDepartments] = useState>({}) + const [hasCustomPermissions, setHasCustomPermissions] = useState>({}) const [materialTypes, setMaterialTypes] = useState([]) const [savingDept, setSavingDept] = useState(null) + + // 对话框状态 + const [showCopyDialog, setShowCopyDialog] = useState(false) + const [copySourceDept, setCopySourceDept] = useState('') + const [selectedCopyTargets, setSelectedCopyTargets] = useState([]) + + const [showLinkDialog, setShowLinkDialog] = useState(false) + const [linkTargetDept, setLinkTargetDept] = useState('') + const [selectedLinkSource, setSelectedLinkSource] = useState('') + + // 构建部门树形结构(与部门管理和用户管理逻辑统一) + const departmentTree = useMemo(() => { + // 找出所有顶级部门(没有parentId或parentId不在列表中) + const topLevelDepts = departments.filter(dept => !dept.parentId || !departments.find(d => d.id === dept.parentId)) + + // 构建子部门映射 + const childrenMap = new Map() + departments.forEach(dept => { + if (dept.parentId) { + if (!childrenMap.has(dept.parentId)) { + childrenMap.set(dept.parentId, []) + } + childrenMap.get(dept.parentId)!.push(dept as Department) + } + }) + + // 排序子部门 + childrenMap.forEach((children) => { + children.sort((a, b) => { + const sortA = a.sortOrder ?? 0 + const sortB = b.sortOrder ?? 0 + if (sortA !== sortB) return sortA - sortB + return a.name.localeCompare(b.name) + }) + }) + + return { topLevelDepts: topLevelDepts as Department[], childrenMap } + }, [departments]) useEffect(() => { fetchPermissions() @@ -47,6 +94,7 @@ export default function DepartmentPermissionsTab({ setEditedPermissions(data.permissions || {}) setDirtyDepartments(new Set()) setLinkedDepartments(data.linkedDepartments || {}) + setHasCustomPermissions(data.hasCustomPermissions || {}) setMaterialTypes(data.materialTypes || []) } else { showToast('获取权限配置失败', 'error') @@ -59,13 +107,13 @@ export default function DepartmentPermissionsTab({ } } - const toggleDepartment = (dept: string) => { + const toggleDepartment = (deptId: string) => { setExpandedDepartments((prev) => { const newSet = new Set(prev) - if (newSet.has(dept)) { - newSet.delete(dept) + if (newSet.has(deptId)) { + newSet.delete(deptId) } else { - newSet.add(dept) + newSet.add(deptId) } return newSet }) @@ -174,7 +222,7 @@ export default function DepartmentPermissionsTab({ } } - const handleCopyPermissions = async (sourceDept: string) => { + const handleCopyPermissions = (sourceDept: string) => { const targetDepts = departments .map((d) => d.name) .filter((name) => name !== sourceDept) @@ -184,25 +232,31 @@ export default function DepartmentPermissionsTab({ return } - const confirmed = window.confirm( - `确定要将"${sourceDept}"的权限配置复制到所有其他部门吗?\n这将覆盖目标部门的所有权限配置。` - ) + setCopySourceDept(sourceDept) + setSelectedCopyTargets([]) + setShowCopyDialog(true) + } - if (!confirmed) return + const handleConfirmCopy = async () => { + if (selectedCopyTargets.length === 0) { + showToast('请至少选择一个目标部门', 'error') + return + } try { const response = await fetch('/api/admin/department-permissions/copy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - sourceDepartment: sourceDept, - targetDepartments: targetDepts, + sourceDepartment: copySourceDept, + targetDepartments: selectedCopyTargets, }), }) if (response.ok) { await fetchPermissions() showToast('权限复制成功') + setShowCopyDialog(false) } else { const error = await response.json() showToast(error.error || '复制失败', 'error') @@ -213,7 +267,7 @@ export default function DepartmentPermissionsTab({ } } - const handleLinkPermissions = async (targetDept: string) => { + const handleLinkPermissions = (targetDept: string) => { const sourceDepts = departments .map((d) => d.name) .filter((name) => name !== targetDept) @@ -223,12 +277,14 @@ export default function DepartmentPermissionsTab({ return } - const sourceDept = window.prompt( - `请输入要关联的源部门名称:\n可用部门:${sourceDepts.join(', ')}`, - '' - ) + setLinkTargetDept(targetDept) + setSelectedLinkSource('') + setShowLinkDialog(true) + } - if (!sourceDept || !sourceDepts.includes(sourceDept)) { + const handleConfirmLink = async () => { + if (!selectedLinkSource) { + showToast('请选择一个源部门', 'error') return } @@ -237,14 +293,15 @@ export default function DepartmentPermissionsTab({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - targetDepartment: targetDept, - sourceDepartment: sourceDept, + targetDepartment: linkTargetDept, + sourceDepartment: selectedLinkSource, }), }) if (response.ok) { await fetchPermissions() showToast('权限关联成功') + setShowLinkDialog(false) } else { const error = await response.json() showToast(error.error || '关联失败', 'error') @@ -279,9 +336,376 @@ export default function DepartmentPermissionsTab({ } } - const filteredDepartments = searchKeyword - ? departments.filter((d) => d.name.includes(searchKeyword)) - : departments + // 获取父部门名称 + const getParentDepartmentName = (deptName: string): string | null => { + const dept = departments.find((d) => d.name === deptName) + if (!dept?.parentId) return null + const parentDept = departments.find((d) => d.id === dept.parentId) + return parentDept?.name || null + } + + // 判断权限是否继承自父部门 + const isInheritedFromParent = (deptName: string): boolean => { + // 如果已关联到其他部门,不是继承 + if (linkedDepartments[deptName]) return false + // 如果有自定义权限配置,不是继承 + if (hasCustomPermissions[deptName]) return false + // 如果有父部门,且没有自定义配置,则是继承 + return !!getParentDepartmentName(deptName) + } + + // 使用父部门权限(删除自定义配置,恢复继承) + const handleUseParentPermissions = async (department: string) => { + const parentDept = getParentDepartmentName(department) + if (!parentDept) { + showToast('该部门没有父部门,无法使用父部门权限', 'error') + return + } + + const confirmed = window.confirm( + `确定要将"${department}"的权限还原为继承自"${parentDept}"吗?\n这将删除所有自定义权限配置。` + ) + + if (!confirmed) return + + try { + const response = await fetch( + `/api/admin/department-permissions/inherit?department=${encodeURIComponent(department)}`, + { method: 'DELETE' } + ) + + if (response.ok) { + await fetchPermissions() + showToast('已恢复为继承父部门权限') + } else { + const error = await response.json() + showToast(error.error || '操作失败', 'error') + } + } catch (error) { + console.error('Error using parent permissions:', error) + showToast('操作失败', 'error') + } + } + + // 创建独立配置(基于父部门权限) + const handleCreateIndependentPermissions = async (department: string) => { + const parentDept = getParentDepartmentName(department) + if (!parentDept) { + showToast('该部门没有父部门,无法基于父部门创建配置', 'error') + return + } + + if (hasCustomPermissions[department]) { + showToast('该部门已有自定义权限配置', 'error') + return + } + + try { + const response = await fetch('/api/admin/department-permissions/inherit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ department }), + }) + + if (response.ok) { + await fetchPermissions() + showToast('已创建独立配置,可以开始编辑') + } else { + const error = await response.json() + showToast(error.error || '创建失败', 'error') + } + } catch (error) { + console.error('Error creating independent permissions:', error) + showToast('创建失败', 'error') + } + } + + // 递归检查部门或其子部门是否匹配搜索关键词 + const hasMatchingDepartment = (dept: Department): boolean => { + if (searchKeyword && dept.name.toLowerCase().includes(searchKeyword.toLowerCase())) { + return true + } + const children = departmentTree.childrenMap.get(dept.id) || [] + return children.some(hasMatchingDepartment) + } + + // 递归渲染部门树 + const renderDepartmentNode = (dept: Department, level: number = 0): React.ReactNode | null => { + const children = departmentTree.childrenMap.get(dept.id) || [] + const hasChildren = children.length > 0 + const isExpanded = expandedDepartments.has(dept.id) + const deptName = dept.name + const linkedTo = linkedDepartments[deptName] + const deptPermissions = editedPermissions[deptName] || {} + const isDirty = dirtyDepartments.has(deptName) + const indent = level * 24 + + // 如果有关键词搜索,检查是否匹配(包括子部门) + if (searchKeyword && !hasMatchingDepartment(dept)) { + return null + } + + return ( +
+
0 ? '16px' : '0', marginTop: level > 0 ? '8px' : '0' }} + > +
toggleDepartment(dept.id)} + > +
+ {/* 展开/折叠图标 */} + {hasChildren ? ( +
+ + + +
+ ) : ( +
+ )} + + {/* 部门图标 */} + + + + + {deptName} + {dept.code && ( + + {dept.code} + + )} + {/* 权限状态显示 */} + {linkedTo ? ( + + (🔗 关联到: {linkedTo}) + + ) : isInheritedFromParent(deptName) ? ( + + (🔗 继承自: {getParentDepartmentName(deptName)}) + + ) : hasCustomPermissions[deptName] ? ( + + (✏️ 自定义配置) + + ) : null} +
+
e.stopPropagation()}> + {/* 主要操作:权限继承相关 */} + {!linkedTo && ( + <> + {isInheritedFromParent(deptName) ? ( + + ) : hasCustomPermissions[deptName] ? ( + + ) : null} + + )} + + {/* 高级操作:复制和关联 */} + {linkedTo ? ( + + ) : ( + <> + + + + )} +
+
+ + {isExpanded && ( +
+
+ 批量操作: + + + + + + +
+ +
+
+ + + + + + + + + + + {materialTypes.map((materialType) => { + const perm = deptPermissions[materialType] + const isLinked = !!linkedTo + const isInherited = isInheritedFromParent(deptName) + const canView = perm?.canView ?? false + const canDownload = perm?.canDownload ?? false + const canShare = perm?.canShare ?? true + const isDisabled = isLinked || isInherited + + return ( + + + + + + + ) + })} + +
+ 素材类型 + + 可见 + + 下载 + + 分享 +
+
+ {materialType} + {isInherited && ( + + 🔗 + + )} +
+
+ + updateLocalPermission(deptName, materialType, 'canView', e.target.checked) + } + className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''} + /> + + + updateLocalPermission(deptName, materialType, 'canDownload', e.target.checked) + } + className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''} + /> + + + updateLocalPermission(deptName, materialType, 'canShare', e.target.checked) + } + className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + title={isDisabled ? (isLinked ? '已关联,无法修改' : '继承状态,请先独立配置') : ''} + /> +
+
+
+ )} +
+ + {/* 递归渲染子部门 */} + {isExpanded && hasChildren && ( +
+ {children.map(child => renderDepartmentNode(child, level + 1))} +
+ )} +
+ ) + } if (loading) { return ( @@ -325,201 +749,118 @@ export default function DepartmentPermissionsTab({
- {filteredDepartments.length === 0 ? ( + {departmentTree.topLevelDepts.length === 0 ? (
{searchKeyword ? '未找到匹配的部门' : '暂无部门'}
) : ( - filteredDepartments.map((dept) => { - const deptName = dept.name - const isExpanded = expandedDepartments.has(deptName) - const linkedTo = linkedDepartments[deptName] - const deptPermissions = editedPermissions[deptName] || {} - const isDirty = dirtyDepartments.has(deptName) - - return ( -
-
toggleDepartment(deptName)} - > -
- - {isExpanded ? '▼' : '▶'} - - {deptName} - {linkedTo && ( - - (已关联到: {linkedTo}) - - )} -
-
e.stopPropagation()}> - - {linkedTo ? ( - - ) : ( - - )} -
-
- - {isExpanded && ( -
-
- 批量操作: - - - - - - -
- -
-
- - - - - - - - - - - {materialTypes.map((materialType) => { - const perm = deptPermissions[materialType] - const isLinked = !!linkedTo - const canView = perm?.canView ?? false - const canDownload = perm?.canDownload ?? false - const canShare = perm?.canShare ?? true - - return ( - - - - - - - ) - })} - -
- 素材类型 - - 可见 - - 下载 - - 分享 -
- {materialType} - - - updateLocalPermission(deptName, materialType, 'canView', e.target.checked) - } - className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" - /> - - - updateLocalPermission(deptName, materialType, 'canDownload', e.target.checked) - } - className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" - /> - - - updateLocalPermission(deptName, materialType, 'canShare', e.target.checked) - } - className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" - /> -
-
-
- )} -
- ) - }) + departmentTree.topLevelDepts.map(dept => renderDepartmentNode(dept, 0)) )}
+ + {/* 复制权限对话框 */} + {showCopyDialog && ( +
+
+

+ 复制权限配置 +

+

+ 将 {copySourceDept} 的权限配置复制到以下部门: +

+
+
+ {departments + .filter((d) => d.name !== copySourceDept) + .map((dept) => ( + + ))} +
+
+
+ + +
+
+
+ )} + + {/* 关联权限对话框 */} + {showLinkDialog && ( +
+
+

+ 关联权限配置 +

+

+ 将 {linkTargetDept} 的权限关联到以下部门: +

+
+ +
+
+

+ 💡 关联后,{linkTargetDept} 的权限将实时跟随目标部门,且无法独立修改权限配置。 +

+
+
+ + +
+
+
+ )}
) } diff --git a/components/DepartmentTreeSelect.tsx b/components/DepartmentTreeSelect.tsx new file mode 100644 index 0000000..e6c8277 --- /dev/null +++ b/components/DepartmentTreeSelect.tsx @@ -0,0 +1,231 @@ +/** + * 部门树形下拉选择器组件 + * 用于在表单中选择部门,支持树形结构展示 + */ + +'use client' + +import { useState, useRef, useEffect } from 'react' + +interface Department { + id: string + name: string + parentId?: string | null + code?: string | null + children?: Department[] +} + +interface DepartmentTreeSelectProps { + departments: Department[] + value: string + onChange: (value: string) => void + placeholder?: string + className?: string + allowEmpty?: boolean + emptyLabel?: string +} + +export default function DepartmentTreeSelect({ + departments, + value, + onChange, + placeholder = '请选择部门', + className = '', + allowEmpty = true, + emptyLabel = '未分配', +}: DepartmentTreeSelectProps) { + const [isOpen, setIsOpen] = useState(false) + const [expandedIds, setExpandedIds] = useState>(new Set()) + const wrapperRef = useRef(null) + + // 点击外部关闭下拉框 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + // 构建部门树 + const buildTree = (depts: Department[]): Department[] => { + const deptMap = new Map() + const rootDepts: Department[] = [] + + // 创建映射 + depts.forEach(dept => { + deptMap.set(dept.id, { ...dept, children: [] }) + }) + + // 构建树 + depts.forEach(dept => { + const node = deptMap.get(dept.id)! + if (dept.parentId && deptMap.has(dept.parentId)) { + const parent = deptMap.get(dept.parentId)! + if (!parent.children) parent.children = [] + parent.children.push(node) + } else { + rootDepts.push(node) + } + }) + + return rootDepts + } + + const treeData = buildTree(departments) + + // 获取选中的部门名称 + const getSelectedName = (): string => { + if (!value) return emptyLabel + const dept = departments.find(d => d.id === value || d.name === value) + return dept ? dept.name : (value || emptyLabel) + } + + // 获取当前值的部门ID(用于匹配) + const getValueId = (): string => { + if (!value) return '' + const dept = departments.find(d => d.id === value || d.name === value) + return dept ? dept.id : value + } + + // 切换展开/收起 + const toggleExpand = (deptId: string, e: React.MouseEvent) => { + e.stopPropagation() + setExpandedIds(prev => { + const newSet = new Set(prev) + if (newSet.has(deptId)) { + newSet.delete(deptId) + } else { + newSet.add(deptId) + } + return newSet + }) + } + + // 选择部门 + const handleSelect = (deptId: string, deptName: string) => { + onChange(deptId) + setIsOpen(false) + } + + // 递归渲染部门树 + const renderDepartmentTree = (depts: Department[], level: number = 0): React.ReactNode => { + const currentValueId = getValueId() + return depts.map(dept => { + const hasChildren = dept.children && dept.children.length > 0 + const isExpanded = expandedIds.has(dept.id) + const isSelected = currentValueId === dept.id + + return ( +
+
handleSelect(dept.id, dept.name)} + > + {/* 展开/折叠按钮 */} + {hasChildren ? ( +
toggleExpand(dept.id, e)} + > + + + +
+ ) : ( +
+ )} + + {/* 部门图标 */} + + + + + {/* 部门名称 */} + + {dept.name} + + + {/* 选中标记 */} + {isSelected && ( + + + + )} +
+ + {/* 子部门 */} + {hasChildren && isExpanded && ( +
+ {renderDepartmentTree(dept.children!, level + 1)} +
+ )} +
+ ) + }) + } + + return ( +
+ {/* 输入框 */} + + + {/* 下拉列表 */} + {isOpen && ( +
+ {allowEmpty && ( +
handleSelect('', '')} + > +
+ {emptyLabel} + {!value && ( + + + + )} +
+ )} + {treeData.length > 0 ? ( + renderDepartmentTree(treeData) + ) : ( +
+ 暂无部门 +
+ )} +
+ )} +
+ ) +} + diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index 65d4d8c..808fb17 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -50,6 +50,7 @@ import { BatchBuyerShowUI, BatchXiaohongshuUI, } from '@/components/upload/SpecialBatchUploadUI' +import ProductPackageUploader from '@/components/upload/ProductPackageUploader' interface UploadModalProps { onClose: () => void @@ -1350,6 +1351,19 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { }) } catch (error) { console.error('Upload error:', error) + + // 如果使用了分片上传,清理可能残留的 IndexedDB 状态 + if (zipFile && zipFile.size >= CHUNK_THRESHOLD) { + try { + const { deleteUploadState, generateFileId } = await import('@/lib/chunk-upload') + const fileId = generateFileId(zipFile) + await deleteUploadState(fileId) + console.log('[UploadModal] 已清理上传状态:', fileId) + } catch (cleanupError) { + console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError) + } + } + setError((error as Error)?.message || '上传失败,请稍后再试') setUploading(false) } @@ -1419,11 +1433,31 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { +
+ {/* 产品全案上传组件 */} + {materialType === '产品全案' && ( +
+ { + onSuccess(); + onClose(); + }} + onCancel={onClose} + categories={categories} + scenes={scenes} + platforms={platforms} + productCategories={productCategories} + onRefreshCategories={fetchCategories} + onRefreshScenes={fetchScenes} + /> +
+ )} + {/* 批量上传模式:可折叠文件列表 */} - {isBatchMode && batchFiles.length > 0 && ( + {materialType !== '产品全案' && isBatchMode && batchFiles.length > 0 && ( )} - {/* 预览图片(非详情页类型才显示,批量模式下隐藏) */} - {materialType !== '详情页' && !isBatchMode && ( + {/* 预览图片(非详情页类型才显示,批量模式下隐藏,产品全案不显示) */} + {materialType !== '详情页' && materialType !== '产品全案' && !isBatchMode && ( )} - {/* 表单字段 - 使用 UploadForm 组件 */} + {/* 表单字段 - 使用 UploadForm 组件(产品全案不显示) */} + {materialType !== '产品全案' && ( + )} - {/* Material File */} + {/* Material File(产品全案不显示) */} + {materialType !== '产品全案' && (
)}
- )} + )}
+ )} {/* 上传进度 */} {uploading && ( @@ -1850,7 +1888,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { )} - {/* Buttons */} + {/* Buttons(产品全案不显示,因为有自己的按钮) */} + {materialType !== '产品全案' && (
+ )} diff --git a/components/admin/DepartmentManagement.tsx b/components/admin/DepartmentManagement.tsx index 963fb9e..e9cb364 100644 --- a/components/admin/DepartmentManagement.tsx +++ b/components/admin/DepartmentManagement.tsx @@ -1,12 +1,14 @@ /** - * 部门管理组件 + * 部门管理组件(阶段三更新:支持层级结构、编辑和创建子部门) * 提取自 app/admin/page.tsx 的部门管理功能 */ 'use client' -import { useState } from 'react' +import { useState, useMemo, useEffect } from 'react' import type { Department } from '@/hooks/useAdminState' +import DepartmentTreeSelect from '@/components/DepartmentTreeSelect' +import { getDepartmentCollator } from '@/lib/departments' interface DepartmentManagementProps { departments: Department[] @@ -15,6 +17,13 @@ interface DepartmentManagementProps { fetchDepartments: () => Promise } +interface User { + id: string + username: string + realName?: string | null + email: string +} + export default function DepartmentManagement({ departments, setDepartments, @@ -23,8 +32,210 @@ export default function DepartmentManagement({ }: DepartmentManagementProps) { const [newDepartmentName, setNewDepartmentName] = useState('') const [addingDepartment, setAddingDepartment] = useState(false) + const [expandedDepts, setExpandedDepts] = useState>(new Set()) + // 默认所有部门收起(expandedDepts为空Set即表示全部收起) + + // 编辑和创建子部门相关状态 + const [editingDept, setEditingDept] = useState(null) + const [creatingChildDept, setCreatingChildDept] = useState(null) + const [users, setUsers] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(false) + + // 编辑表单状态 + const [editForm, setEditForm] = useState({ + name: '', + code: '', + parentId: '', + status: 'active', + description: '', + managerId: '', + sortOrder: 0, + }) + + // 创建子部门表单状态 + const [childForm, setChildForm] = useState({ + name: '', + code: '', + description: '', + managerId: '', + sortOrder: 0, + }) - // 添加部门 + // 加载用户列表(用于选择部门管理员) + useEffect(() => { + const loadUsers = async () => { + setLoadingUsers(true) + try { + const response = await fetch('/api/admin/users') + if (response.ok) { + const data = await response.json() + setUsers(data) + } + } catch (error) { + console.error('Error loading users:', error) + } finally { + setLoadingUsers(false) + } + } + loadUsers() + }, []) + + // 构建部门树形结构(阶段三) + const departmentTree = useMemo(() => { + // 找出所有顶级部门(没有parentId或parentId不在列表中) + const topLevelDepts = departments.filter(dept => !dept.parentId || !departments.find(d => d.id === dept.parentId)) + + // 构建子部门映射 + const childrenMap = new Map() + departments.forEach(dept => { + if (dept.parentId) { + if (!childrenMap.has(dept.parentId)) { + childrenMap.set(dept.parentId, []) + } + childrenMap.get(dept.parentId)!.push(dept) + } + }) + + // 排序子部门(使用中文拼音排序,参考 flnxi_sucai) + const collator = getDepartmentCollator() + childrenMap.forEach((children) => { + children.sort((a, b) => { + const sortA = a.sortOrder ?? 0 + const sortB = b.sortOrder ?? 0 + if (sortA !== sortB) return sortA - sortB + return collator.compare(a.name, b.name) + }) + }) + + return { topLevelDepts, childrenMap } + }, [departments]) + + // 获取所有可用父部门(排除自己和自己的子部门) + const getAvailableParents = (excludeId?: string): Department[] => { + return departments.filter(dept => { + if (excludeId && dept.id === excludeId) return false + if (excludeId && dept.parentId === excludeId) return false + // 简单检查:排除可能是子部门的部门(更严格的检查在API端) + return true + }) + } + + // 切换部门展开/折叠 + const toggleExpand = (deptId: string) => { + setExpandedDepts(prev => { + const newSet = new Set(prev) + if (newSet.has(deptId)) { + newSet.delete(deptId) + } else { + newSet.add(deptId) + } + return newSet + }) + } + + // 打开编辑弹窗 + const handleEditDepartment = (dept: Department) => { + setEditingDept(dept) + setEditForm({ + name: dept.name, + code: dept.code || '', + parentId: dept.parentId || '', + status: dept.status || 'active', + description: dept.description || '', + managerId: dept.managerId || '', + sortOrder: dept.sortOrder || 0, + }) + } + + // 保存编辑 + const handleSaveEdit = async () => { + if (!editingDept) return + if (!editForm.name.trim()) { + showToast('部门名称不能为空', 'error') + return + } + + try { + const response = await fetch(`/api/departments/${editingDept.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: editForm.name.trim(), + code: editForm.code.trim() || null, + parentId: editForm.parentId || null, + status: editForm.status, + description: editForm.description.trim() || null, + managerId: editForm.managerId || null, + sortOrder: parseInt(String(editForm.sortOrder)) || 0, + }), + }) + + if (response.ok) { + await fetchDepartments() + setEditingDept(null) + showToast('部门更新成功') + } else { + const error = await response.json() + showToast(error.error || '更新失败', 'error') + } + } catch (error) { + console.error('Update department error:', error) + showToast('更新失败,请稍后重试', 'error') + } + } + + // 打开创建子部门弹窗 + const handleCreateChild = (parentDept: Department) => { + setCreatingChildDept(parentDept) + setChildForm({ + name: '', + code: '', + description: '', + managerId: '', + sortOrder: 0, + }) + } + + // 保存创建子部门 + const handleSaveChild = async () => { + if (!creatingChildDept) return + if (!childForm.name.trim()) { + showToast('部门名称不能为空', 'error') + return + } + + try { + const response = await fetch('/api/departments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: childForm.name.trim(), + parentId: creatingChildDept.id, + code: childForm.code.trim() || null, + description: childForm.description.trim() || null, + managerId: childForm.managerId || null, + sortOrder: parseInt(String(childForm.sortOrder)) || 0, + }), + }) + + if (response.ok) { + await fetchDepartments() + setCreatingChildDept(null) + setChildForm({ name: '', code: '', description: '', managerId: '', sortOrder: 0 }) + // 自动展开父部门 + setExpandedDepts(prev => new Set(prev).add(creatingChildDept.id)) + showToast('子部门创建成功') + } else { + const error = await response.json() + showToast(error.error || '创建失败', 'error') + } + } catch (error) { + console.error('Create child department error:', error) + showToast('创建失败,请稍后重试', 'error') + } + } + + // 添加部门(顶级部门) const handleAddDepartment = async () => { if (!newDepartmentName.trim()) { showToast('请输入部门名称', 'error') @@ -60,12 +271,12 @@ export default function DepartmentManagement({ if (!confirm(`确定要删除部门"${name}"吗?`)) return try { - const response = await fetch(`/api/departments?id=${id}`, { + const response = await fetch(`/api/departments/${id}`, { method: 'DELETE', }) if (response.ok) { - setDepartments(prev => prev.filter(d => d.id !== id)) + await fetchDepartments() showToast('部门删除成功') } else { const error = await response.json() @@ -77,84 +288,459 @@ export default function DepartmentManagement({ } } - return ( -
-
-

部门管理

-

管理系统中的所有部门

-
+ // 递归渲染部门树(优化层级视觉区分,参考 flnxi_sucai) + const renderDepartmentNode = (dept: Department, level: number = 0) => { + const children = departmentTree.childrenMap.get(dept.id) || [] + const hasChildren = children.length > 0 + const isExpanded = expandedDepts.has(dept.id) + const indent = level * 24 -
- {/* 添加部门 */} -
- setNewDepartmentName(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-gray-400 outline-none" - onKeyDown={(e) => { - if (e.key === 'Enter' && !addingDepartment) { - handleAddDepartment() - } - }} - /> - -
+ // 根据层级应用不同的视觉样式(保持当前项目的视觉风格) + const getLevelStyles = () => { + if (level === 0) { + return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800' + } else if (level === 1) { + return 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700' + } else { + return 'bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-700' + } + } - {/* 部门列表 */} -
- {departments.map((dept) => ( -
-
- - - - {dept.name} -
- +
+ ) : ( +
+ )} + + {/* 部门图标(根据层级改变颜色) */} + + + + + {/* 部门信息 */} +
+
+ {dept.name} + + {/* 部门编码(阶段三) */} + {dept.code && ( + + {dept.code} + + )} + + {/* 层级标识(阶段三) */} + {dept.level !== undefined && dept.level > 1 && ( + + L{dept.level} + + )} + + {/* 状态标识(阶段三) */} + {dept.status && dept.status !== 'active' && ( + + {dept.status === 'disabled' ? '已禁用' : dept.status === 'archived' ? '已归档' : dept.status} + + )} + + {/* 部门管理员(阶段三) */} + {dept.manager && ( + + 负责人: {dept.manager.realName || dept.manager.username} + + )} +
+ + {/* 部门描述(阶段三) */} + {dept.description && ( +
+ {dept.description} +
+ )}
- ))} +
+ + {/* 操作按钮 */} +
e.stopPropagation()}> + + + +
- {departments.length === 0 && ( -
- 暂无部门,请添加新部门 + {/* 子部门(递归渲染,改进层级视觉区分) */} + {hasChildren && isExpanded && ( +
+ {children.map(child => renderDepartmentNode(child, level + 1))}
)}
-
+ ) + } + + return ( + <> +
+
+

部门管理

+

+ 管理系统中的所有部门(阶段三:支持层级结构、编辑和创建子部门) +

+
+ +
+ {/* 添加部门 */} +
+ setNewDepartmentName(e.target.value)} + className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white focus:ring-2 focus:ring-gray-400 outline-none" + onKeyDown={(e) => { + if (e.key === 'Enter' && !addingDepartment) { + handleAddDepartment() + } + }} + /> + +
+ + {/* 部门树形列表(阶段三) */} +
+ {departmentTree.topLevelDepts.length > 0 ? ( + departmentTree.topLevelDepts + .sort((a, b) => { + const sortA = a.sortOrder ?? 0 + const sortB = b.sortOrder ?? 0 + if (sortA !== sortB) return sortA - sortB + return a.name.localeCompare(b.name) + }) + .map(dept => renderDepartmentNode(dept, 0)) + ) : ( +
+ 暂无部门,请添加新部门 +
+ )} +
+
+
+ + {/* 编辑部门弹窗 */} + {editingDept && ( +
+
+

+ 编辑部门:{editingDept.name} +

+ +
+
+ + setEditForm({ ...editForm, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white" + placeholder="请输入部门名称" + /> +
+ +
+
+ + setEditForm({ ...editForm, code: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-white" + placeholder="如:DEPT-001" + /> +
+ +
+ + +
+
+ +
+ + setEditForm({ ...editForm, parentId: deptId })} + placeholder="请选择父部门" + allowEmpty={true} + emptyLabel="无(顶级部门)" + className="w-full" + /> +
+ +
+ + +
+ +
+ +