feat: 优化自定义上传模板系统

- 修复内置模板验证问题(builtin-video-material等)
- 添加模板验证工具 lib/template-validator.ts
- 创建模板系统优化需求文档(30个详细需求)
- 优化上传API的模板验证逻辑
- 改进错误提示信息的清晰度
- 添加字段池和分类管理相关需求
- 完善自定义字段的创建、验证、存储逻辑
- 清理过期的 .specstory 和 .trae 文件
This commit is contained in:
WuYongJie 2025-12-13 14:26:43 +08:00
parent f45e27bf06
commit 542c7bcc49
41 changed files with 1730 additions and 79767 deletions

View File

@ -1,31 +0,0 @@
name: CI Check
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Run lint and schema checks
env:
NEXTAUTH_SECRET: test-secret
DATABASE_URL: postgresql://user:password@localhost:5432/db
run: npm run check

View File

@ -0,0 +1,411 @@
# 自定义上传模板系统优化需求文档
## 介绍
本文档定义了对FLNXI素材库自定义上传模板系统的优化需求。
**核心价值**:自定义上传模板系统是整个FLNXI素材库的核心基础设施,它决定了:
- 用户如何上传素材(表单结构)
- 素材包含哪些元数据(字段定义)
- 如何分类和检索素材(字段池集成)
- 如何共享和展示素材(字段显示配置)
作为"上传-保存-共享"工作流的起点,模板系统的稳定性和灵活性直接影响整个系统的可用性。当前系统已经实现了基础功能,但需要进一步优化以提高稳定性、可靠性和用户体验,确保它能够支撑各种复杂的业务场景。
## 术语表
- **自定义模板 (Custom Template)**: 管理员创建的上传模板,存储在数据库中
- **内置模板 (Builtin Template)**: 系统预设的模板,动态生成,不存储在数据库中
- **模板字段 (Template Field)**: 模板中定义的表单字段,如文本框、下拉框等
- **字段池 (Field Pool)**: 集中管理所有模板字段及其选项的系统
- **模板验证 (Template Validation)**: 验证模板ID是否有效的过程
- **字段同步 (Field Sync)**: 将模板字段同步到字段池的过程
## 需求
### 需求 1: 模板验证增强
**用户故事**: 作为系统开发者,我希望模板验证逻辑能够正确区分内置模板和自定义模板,以便用户可以使用任何有效的模板上传素材。
#### 验收标准
1. WHEN 用户使用内置模板ID(以 `builtin-` 开头)上传素材 THEN 系统应验证模板key是否在预设列表中
2. WHEN 用户使用自定义模板ID上传素材 THEN 系统应从数据库查询验证模板是否存在
3. WHEN 模板验证失败 THEN 系统应返回清晰的错误信息,说明是内置模板无效还是自定义模板不存在
4. WHEN 模板ID为空或null THEN 系统应允许上传(向后兼容旧数据)
### 需求 2: 自定义模板字段处理
**用户故事**: 作为管理员,我希望创建的自定义模板字段能够正确保存和读取,以便用户上传时能看到正确的表单字段。
#### 验收标准
1. WHEN 管理员创建自定义模板并添加字段 THEN 系统应正确保存所有字段属性(key, label, type, required等)
2. WHEN 用户选择自定义模板上传素材 THEN 系统应正确读取并显示所有模板字段
3. WHEN 用户填写自定义模板字段并提交 THEN 系统应正确保存字段值到 metadata 或对应的数据库字段
4. WHEN 自定义模板字段类型为 select THEN 系统应正确解析和显示选项列表
5. WHEN 自定义模板字段设置为必填 THEN 系统应在提交前验证该字段不为空
### 需求 3: 模板字段同步优化
**用户故事**: 作为管理员,我希望模板字段能够自动同步到字段池,以便在分类管理中使用这些字段。
#### 验收标准
1. WHEN 管理员创建或更新模板并勾选字段的 `addToCategory` THEN 系统应自动将该字段同步到字段池
2. WHEN 管理员删除模板 THEN 系统应清理该模板相关的字段池数据(如果没有其他模板使用)
3. WHEN 多个模板使用相同的字段key THEN 系统应合并这些字段的选项
4. WHEN 字段选项包含脏数据(如 `[[[xxx]]]`) THEN 系统应自动清洗并规范化选项值
### 需求 4: 模板版本控制
**用户故事**: 作为管理员,我希望在编辑模板时能够检测并发冲突,以避免覆盖其他管理员的修改。
#### 验收标准
1. WHEN 管理员打开模板编辑页面 THEN 系统应记录当前模板版本号
2. WHEN 管理员保存模板 THEN 系统应检查版本号是否与数据库一致
3. WHEN 版本号不一致 THEN 系统应拒绝保存并提示"模板已被其他人修改,请刷新后重试"
4. WHEN 保存成功 THEN 系统应自动递增版本号
### 需求 5: 模板数据完整性
**用户故事**: 作为系统开发者,我希望模板数据始终保持完整性,以避免数据损坏导致的系统错误。
#### 验收标准
1. WHEN 创建或更新模板 THEN 系统应验证所有必填字段(name, materialType, materialKind)
2. WHEN 模板字段的 key 重复 THEN 系统应拒绝保存并提示错误
3. WHEN 模板字段的 optionsStatic 为数组 THEN 系统应自动转换为JSON字符串
4. WHEN 模板的 materialKind 不在允许列表中 THEN 系统应使用默认值 'IMAGE'
5. WHEN 删除模板时有素材正在使用 THEN 系统应警告管理员(但允许删除,素材的 templateId 会变为 null)
### 需求 6: 错误处理和日志
**用户故事**: 作为系统开发者,我希望模板相关操作有完善的错误处理和日志记录,以便快速定位和解决问题。
#### 验收标准
1. WHEN 模板操作发生错误 THEN 系统应记录详细的错误日志(包括错误类型、堆栈、请求数据)
2. WHEN Prisma 返回特定错误代码 THEN 系统应返回用户友好的错误信息
3. WHEN 字段池同步失败 THEN 系统应记录错误但不影响模板保存
4. WHEN 模板验证失败 THEN 系统应返回清晰的错误信息,包含失败原因和建议操作
### 需求 7: 性能优化
**用户故事**: 作为用户,我希望模板加载和保存速度快,以提高工作效率。
#### 验收标准
1. WHEN 获取模板列表 THEN 系统应在 500ms 内返回结果
2. WHEN 保存模板 THEN 系统应在 2秒 内完成(包括字段池同步)
3. WHEN 模板包含大量字段(>20个) THEN 系统应使用事务批量处理,避免多次数据库往返
4. WHEN 字段池同步 THEN 系统应使用批量操作,避免逐个字段处理
### 需求 8: 向后兼容性
**用户故事**: 作为系统维护者,我希望新的优化不会破坏现有功能,以确保系统稳定运行。
#### 验收标准
1. WHEN 旧素材没有 templateId THEN 系统应正常显示和编辑
2. WHEN 旧素材的 templateId 指向已删除的模板 THEN 系统应正常显示(templateId 为 null)
3. WHEN 旧模板没有 materialKind 字段 THEN 系统应使用默认值 'IMAGE'
4. WHEN 旧模板字段没有 addToCategory 属性 THEN 系统应默认为 false
### 需求 9: 模板字段类型支持
**用户故事**: 作为管理员,我希望能够创建多种类型的字段,以满足不同素材的元数据需求。
#### 验收标准
1. WHEN 管理员创建 text 类型字段 THEN 用户上传时应看到单行文本输入框
2. WHEN 管理员创建 textarea 类型字段 THEN 用户上传时应看到多行文本输入框
3. WHEN 管理员创建 select 类型字段 THEN 用户上传时应看到下拉选择框,并能看到所有选项
4. WHEN 管理员创建 number 类型字段 THEN 用户上传时应看到数字输入框,只能输入数字
5. WHEN 管理员创建 date 类型字段 THEN 用户上传时应看到日期选择器
6. WHEN 管理员创建 checkbox 类型字段 THEN 用户上传时应看到复选框
7. WHEN 管理员创建 file 类型字段 THEN 用户上传时应看到文件上传按钮
8. WHEN 管理员创建 image 类型字段 THEN 用户上传时应看到图片上传按钮,并支持预览
### 需求 10: 模板字段验证规则
**用户故事**: 作为管理员,我希望能够为字段设置验证规则,以确保用户输入的数据符合要求。
#### 验收标准
1. WHEN 管理员设置字段为必填 THEN 用户提交时该字段不能为空
2. WHEN 管理员为 text 字段设置最大长度 THEN 用户输入超过长度时应提示错误
3. WHEN 管理员为 number 字段设置最小值和最大值 THEN 用户输入超出范围时应提示错误
4. WHEN 管理员为字段设置正则表达式验证 THEN 用户输入不匹配时应提示错误
5. WHEN 验证失败 THEN 系统应显示清晰的错误提示,说明哪个字段不符合要求
### 需求 11: 模板字段布局配置
**用户故事**: 作为管理员,我希望能够配置字段的布局,以优化上传表单的用户体验。
#### 验收标准
1. WHEN 管理员设置字段布局为 'full' THEN 该字段应占据整行
2. WHEN 管理员设置字段布局为 'half' THEN 该字段应占据半行,可与其他半行字段并排显示
3. WHEN 管理员拖拽调整字段顺序 THEN 上传表单应按新顺序显示字段
4. WHEN 管理员设置字段为只读 THEN 用户上传时该字段应显示但不可编辑
5. WHEN 管理员设置字段的显示条件 THEN 只有满足条件时该字段才显示
### 需求 12: 模板与素材的关联
**用户故事**: 作为用户,我希望能够看到素材使用的模板,以便了解素材的元数据结构。
#### 验收标准
1. WHEN 用户查看素材详情 THEN 系统应显示该素材使用的模板名称
2. WHEN 用户编辑素材 THEN 系统应根据模板动态生成编辑表单
3. WHEN 素材的模板被删除 THEN 素材应仍然可以查看和编辑(使用通用表单)
4. WHEN 用户筛选素材 THEN 系统应支持按模板类型筛选
5. WHEN 管理员查看模板 THEN 系统应显示使用该模板的素材数量
### 需求 13: 模板导入导出
**用户故事**: 作为管理员,我希望能够导入导出模板配置,以便在不同环境间迁移或备份模板。
#### 验收标准
1. WHEN 管理员导出模板 THEN 系统应生成包含所有字段配置的 JSON 文件
2. WHEN 管理员导入模板 THEN 系统应验证 JSON 格式并创建新模板
3. WHEN 导入的模板名称已存在 THEN 系统应提示管理员选择覆盖或重命名
4. WHEN 导入的模板包含无效字段类型 THEN 系统应拒绝导入并提示错误
5. WHEN 导出多个模板 THEN 系统应支持批量导出为单个 JSON 文件
### 需求 14: 模板使用统计
**用户故事**: 作为管理员,我希望能够查看模板的使用情况,以便优化模板配置。
#### 验收标准
1. WHEN 管理员查看模板列表 THEN 系统应显示每个模板的使用次数
2. WHEN 管理员查看模板详情 THEN 系统应显示最近使用该模板上传的素材列表
3. WHEN 管理员查看模板统计 THEN 系统应显示每个字段的填写率
4. WHEN 某个字段填写率很低 THEN 系统应建议管理员考虑将其设为可选或删除
5. WHEN 模板长期未使用 THEN 系统应建议管理员归档该模板
### 需求 15: 模板权限控制
**用户故事**: 作为超级管理员,我希望能够控制哪些管理员可以创建和修改模板,以确保模板配置的安全性。
#### 验收标准
1. WHEN 普通用户访问模板管理页面 THEN 系统应拒绝访问并提示权限不足
2. WHEN 管理员访问模板管理页面 THEN 系统应允许查看和编辑模板
3. WHEN 超级管理员访问模板管理页面 THEN 系统应允许所有操作(包括删除)
4. WHEN 管理员尝试删除正在使用的模板 THEN 系统应警告但允许删除(素材不受影响)
5. WHEN 管理员修改模板 THEN 系统应记录操作日志(谁在何时修改了什么)
### 需求 16: 自定义字段的创建和管理
**用户故事**: 作为管理员,我希望能够灵活地创建和管理模板中的自定义字段,以适应不同类型素材的元数据需求。
#### 验收标准
1. WHEN 管理员在模板编辑器中点击"添加字段" THEN 系统应显示字段配置表单
2. WHEN 管理员输入字段key THEN 系统应实时验证key的唯一性和格式(只允许字母、数字、下划线)
3. WHEN 管理员输入字段label THEN 系统应自动将其作为显示名称
4. WHEN 管理员选择字段类型 THEN 系统应根据类型显示相应的配置选项(如select类型显示选项配置)
5. WHEN 管理员拖拽字段调整顺序 THEN 系统应实时更新字段的 orderIndex
6. WHEN 管理员删除字段 THEN 系统应确认操作并警告可能影响已有素材
7. WHEN 管理员复制字段 THEN 系统应创建一个新字段,key自动添加后缀避免冲突
### 需求 17: 自定义字段的选项管理(select类型)
**用户故事**: 作为管理员,我希望能够为下拉选择字段配置选项列表,并支持动态添加和编辑选项。
#### 验收标准
1. WHEN 管理员为select字段添加选项 THEN 系统应支持逐行输入或JSON数组格式
2. WHEN 管理员输入的选项包含特殊字符或空格 THEN 系统应自动清洗并规范化
3. WHEN 管理员输入重复的选项 THEN 系统应自动去重
4. WHEN 管理员输入嵌套数组格式(如 `[[[option]]]`) THEN 系统应自动展开为单层数组
5. WHEN 管理员保存模板 THEN 系统应将选项数组转换为JSON字符串存储
6. WHEN 用户上传素材时 THEN 系统应正确解析选项并显示在下拉框中
7. WHEN 管理员修改选项列表 THEN 系统应保持已有素材的选项值不变(即使选项被删除)
### 需求 18: 自定义字段的验证配置
**用户故事**: 作为管理员,我希望能够为自定义字段配置验证规则,确保用户输入的数据质量。
#### 验收标准
1. WHEN 管理员勾选"必填" THEN 用户上传时该字段必须填写
2. WHEN 管理员为text字段设置最大长度 THEN 系统应在前端和后端都进行验证
3. WHEN 管理员为number字段设置范围 THEN 系统应验证输入值在范围内
4. WHEN 管理员设置正则表达式验证 THEN 系统应在用户输入时实时验证并提示
5. WHEN 管理员设置自定义错误提示 THEN 验证失败时应显示自定义消息
6. WHEN 验证规则冲突(如最小值大于最大值) THEN 系统应在保存模板时提示错误
7. WHEN 用户提交表单 THEN 系统应按字段顺序依次验证,并显示所有错误
### 需求 19: 自定义字段的条件显示
**用户故事**: 作为管理员,我希望能够设置字段的显示条件,根据其他字段的值动态显示或隐藏字段。
#### 验收标准
1. WHEN 管理员为字段设置显示条件 THEN 系统应提供可视化的条件编辑器
2. WHEN 管理员设置"当字段A等于值X时显示" THEN 用户上传时应根据条件动态显示字段
3. WHEN 管理员设置多个条件(AND/OR逻辑) THEN 系统应正确计算条件结果
4. WHEN 条件字段的值改变 THEN 系统应实时重新计算并更新字段显示状态
5. WHEN 隐藏的字段有值 THEN 系统应保留该值(不清空)
6. WHEN 条件配置循环依赖(A依赖B,B依赖A) THEN 系统应在保存时检测并拒绝
7. WHEN 条件字段被删除 THEN 系统应警告并清除相关的条件配置
### 需求 20: 自定义字段的数据存储和读取
**用户故事**: 作为系统开发者,我希望自定义字段的数据能够可靠地存储和读取,支持不同的存储策略。
#### 验收标准
1. WHEN 自定义字段key与Material模型的标准字段匹配 THEN 系统应直接存储到对应的数据库字段
2. WHEN 自定义字段key不匹配标准字段 THEN 系统应将其存储到 metadata JSON字段中
3. WHEN 用户上传素材 THEN 系统应正确解析templateFields并分配到对应的存储位置
4. WHEN 用户查看素材详情 THEN 系统应从标准字段和metadata中合并读取所有字段值
5. WHEN 用户编辑素材 THEN 系统应根据模板动态生成表单,并预填充现有值
6. WHEN metadata字段超过数据库限制 THEN 系统应压缩或分片存储
7. WHEN 模板字段被删除但素材中有该字段的值 THEN 系统应保留数据但不显示在编辑表单中
### 需求 21: 自定义字段的迁移和兼容性
**用户故事**: 作为系统维护者,我希望在修改模板字段时,已有素材的数据能够平滑迁移,不会丢失或损坏。
#### 验收标准
1. WHEN 管理员修改字段的key THEN 系统应提供数据迁移工具,将旧key的值迁移到新key
2. WHEN 管理员修改字段类型(如text改为select) THEN 系统应验证现有数据是否兼容
3. WHEN 字段类型不兼容 THEN 系统应警告并提供数据转换选项
4. WHEN 管理员删除字段 THEN 系统应保留素材中的数据(存储在metadata中)但不显示
5. WHEN 管理员恢复已删除的字段(相同key) THEN 系统应自动恢复素材中的数据
6. WHEN 模板版本升级 THEN 系统应支持批量更新素材的字段结构
7. WHEN 数据迁移失败 THEN 系统应回滚并记录详细的错误日志
### 需求 22: 自定义字段的性能优化
**用户故事**: 作为用户,我希望即使模板包含大量自定义字段,上传和编辑素材的速度也不会明显变慢。
#### 验收标准
1. WHEN 模板包含超过30个字段 THEN 系统应使用虚拟滚动或分页显示
2. WHEN 用户填写表单 THEN 系统应使用防抖技术减少验证频率
3. WHEN 用户提交表单 THEN 系统应批量验证所有字段,避免多次往返
4. WHEN 字段有复杂的显示条件 THEN 系统应缓存条件计算结果
5. WHEN 加载素材详情 THEN 系统应并行读取标准字段和metadata
6. WHEN 保存素材 THEN 系统应使用事务确保标准字段和metadata同时更新
7. WHEN metadata字段过大 THEN 系统应使用压缩算法减少存储空间
### 需求 23: 自定义字段的错误恢复
**用户故事**: 作为用户,我希望在填写表单时如果发生错误,我的输入不会丢失,可以继续编辑。
#### 验收标准
1. WHEN 用户填写表单时 THEN 系统应自动保存草稿到浏览器本地存储
2. WHEN 用户刷新页面或意外关闭 THEN 系统应提示恢复未保存的数据
3. WHEN 提交失败(网络错误、验证失败等) THEN 系统应保留用户输入,允许修改后重新提交
4. WHEN 用户在多个标签页同时编辑 THEN 系统应检测冲突并提示用户
5. WHEN 草稿数据过期(超过7天) THEN 系统应自动清理
6. WHEN 用户主动清除草稿 THEN 系统应提供"清除草稿"按钮
7. WHEN 用户成功提交 THEN 系统应自动清除对应的草稿数据
### 需求 24: 字段池同步的完整性和一致性
**用户故事**: 作为系统开发者,我希望模板字段与字段池之间的同步是完整、一致且可靠的,确保首页筛选和分类管理功能正常工作。
#### 验收标准
1. WHEN 管理员勾选字段的 `addToCategory` 并保存模板 THEN 系统应立即将该字段同步到字段池(FieldDefinition表)
2. WHEN 多个模板包含相同key的字段 THEN 系统应合并这些字段的选项到同一个FieldDefinition记录
3. WHEN 管理员取消勾选字段的 `addToCategory` THEN 系统应检查是否有其他模板使用该字段,如果没有则从字段池删除
4. WHEN 管理员修改字段的选项列表 THEN 系统应更新字段池中的FieldOption记录
5. WHEN 字段池同步过程中发生错误 THEN 系统应记录详细日志但不影响模板保存,并在后台重试
6. WHEN 字段池数据与模板数据不一致 THEN 系统应提供"重新同步"工具,管理员可手动触发全量同步
7. WHEN 系统启动时 THEN 系统应自动检查字段池完整性,发现问题自动修复
### 需求 25: 首页分类Tag按钮的动态生成
**用户故事**: 作为用户,我希望首页的分类Tag按钮能够准确反映字段池中的分类数据,并且加载速度快、交互流畅。
#### 验收标准
1. WHEN 用户访问首页 THEN 系统应从字段池读取所有启用的筛选字段(isFilterEnabled=true)
2. WHEN 字段池包含多个字段 THEN 系统应按 filterOrder 排序显示Tag按钮
3. WHEN 用户点击Tag按钮 THEN 系统应展开该字段的所有选项,并高亮当前选中的选项
4. WHEN 用户选择某个选项 THEN 系统应立即筛选素材列表,并更新URL参数
5. WHEN 字段的选项过多(>20个) THEN 系统应使用虚拟滚动或分页显示
6. WHEN 字段的 filterDisplayName 存在 THEN 系统应优先显示自定义名称,否则显示 label
7. WHEN 管理员在后台修改字段池 THEN 首页应在下次刷新时自动更新Tag按钮(无需重启服务)
### 需求 26: 首页Tag按钮的性能优化
**用户故事**: 作为用户,我希望首页Tag按钮的切换和筛选操作非常流畅,不会出现卡顿或闪烁。
#### 验收标准
1. WHEN 用户切换Tag按钮 THEN 系统应在100ms内完成UI更新(无闪烁)
2. WHEN 用户快速连续点击多个Tag THEN 系统应使用防抖技术,避免重复请求
3. WHEN 素材列表数据量大(>1000条) THEN 系统应使用虚拟滚动和分页加载
4. WHEN 用户选择多个筛选条件 THEN 系统应合并为单次API请求
5. WHEN 筛选结果为空 THEN 系统应显示友好的空状态提示,并建议清除筛选条件
6. WHEN 用户清除所有筛选 THEN 系统应快速恢复到初始状态
7. WHEN Tag按钮数量过多 THEN 系统应使用横向滚动或折叠显示,避免布局混乱
### 需求 27: 后台分类管理的字段池集成
**用户故事**: 作为管理员,我希望在后台分类管理页面能够方便地管理字段池,包括启用/禁用字段、调整顺序、重命名显示名称等。
#### 验收标准
1. WHEN 管理员进入分类管理页面 THEN 系统应显示所有字段池中的字段(包括已启用和未启用)
2. WHEN 管理员拖拽字段调整顺序 THEN 系统应实时更新 filterOrder,并同步到首页Tag按钮顺序
3. WHEN 管理员点击"重命名"按钮 THEN 系统应弹出输入框,允许修改 filterDisplayName
4. WHEN 管理员点击"启用/禁用"按钮 THEN 系统应切换 isFilterEnabled 状态,并立即反映到首页
5. WHEN 管理员点击字段卡片 THEN 系统应在右侧显示该字段的所有选项
6. WHEN 管理员添加/删除/重命名选项 THEN 系统应更新 FieldOption 表,并同步到所有使用该字段的模板
7. WHEN 管理员点击"刷新"按钮 THEN 系统应重新从模板同步字段池数据
### 需求 28: 分类管理的选项清洗和规范化
**用户故事**: 作为管理员,我希望系统能够自动清洗和规范化字段选项,避免脏数据影响用户体验。
#### 验收标准
1. WHEN 字段选项包含多余的括号(如 `[[[选项]]]`) THEN 系统应自动去除多余括号
2. WHEN 字段选项包含前后空格 THEN 系统应自动trim
3. WHEN 字段选项包含特殊字符(如引号、反斜杠) THEN 系统应自动转义或清除
4. WHEN 字段选项为嵌套数组格式(如 `["[选项1,选项2]"]`) THEN 系统应自动展开为单层数组
5. WHEN 字段选项重复 THEN 系统应自动去重
6. WHEN 字段选项为空字符串 THEN 系统应自动过滤
7. WHEN 清洗过程发现无法处理的数据 THEN 系统应记录警告日志并跳过该选项
### 需求 29: 分类管理的批量操作
**用户故事**: 作为管理员,我希望能够批量操作字段和选项,提高管理效率。
#### 验收标准
1. WHEN 管理员选中多个字段 THEN 系统应显示批量操作按钮(启用/禁用/删除)
2. WHEN 管理员批量启用字段 THEN 系统应一次性更新所有选中字段的 isFilterEnabled
3. WHEN 管理员批量删除字段 THEN 系统应确认操作并警告影响范围
4. WHEN 管理员批量导入选项 THEN 系统应支持CSV或JSON格式,并自动清洗数据
5. WHEN 管理员批量导出选项 THEN 系统应生成包含所有字段和选项的JSON文件
6. WHEN 批量操作失败 THEN 系统应显示详细的错误报告,说明哪些操作成功、哪些失败
7. WHEN 批量操作涉及大量数据(>100条) THEN 系统应显示进度条并支持取消
### 需求 30: 分类管理与模板的双向同步
**用户故事**: 作为管理员,我希望分类管理和模板管理之间能够双向同步,修改任何一方都能反映到另一方。
#### 验收标准
1. WHEN 管理员在分类管理中修改字段选项 THEN 系统应自动更新所有使用该字段的模板
2. WHEN 管理员在模板管理中修改字段选项 THEN 系统应自动同步到字段池
3. WHEN 管理员在分类管理中删除字段 THEN 系统应警告哪些模板正在使用该字段
4. WHEN 管理员在模板管理中删除字段 THEN 系统应检查字段池是否还有其他模板使用
5. WHEN 同步过程中发生冲突(如两个管理员同时修改) THEN 系统应使用版本号检测并提示冲突
6. WHEN 同步失败 THEN 系统应回滚所有相关修改,确保数据一致性
7. WHEN 管理员查看同步历史 THEN 系统应显示详细的同步日志(时间、操作、结果)

View File

@ -1,4 +0,0 @@
# SpecStory project identity file
/.project.json
# SpecStory explanation file
/.what-is-this.md

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
<!-- Generated by SpecStory, Markdown v2.0.0 -->
<!-- cursor Session 640fa3e1-356b-4f14-b84b-f485193a4b1e (2025-11-11 11:12Z) -->
# 这些是什么问题 (2025-11-11 11:12Z)
_**User (2025-11-11 11:14Z)**_
@Cursor (cd d:\sucaiku\flnxi_suc...) (735-880) 这些是什么问题
---
_**Agent (model default, mode Agent)**_
---

View File

@ -1,253 +0,0 @@
<!-- Generated by SpecStory, Markdown v2.0.0 -->
<!-- cursor Session 9d64ccae-c147-4d49-8484-ae99f08a45d1 (2025-11-11 11:16Z) -->
# Understanding Docker build warnings and deprecations (2025-11-11 11:16Z)
_**User (2025-11-11 11:14Z)**_
time="2025-11-11T19:14:51+08:00" level=warning msg="/root/youyidian-3d/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion"
#1 [internal] load local bake definitions
#1 reading from stdin 487B done
#1 DONE 0.0s
#2 [base 1/2] FROM docker.io/library/node:18-alpine
#2 DONE 0.0s
#3 [base 2/2] RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
#3 CACHED
#4 [builder 1/8] WORKDIR /app
#4 CACHED
#5 [runner 2/13] RUN apk add --no-cache libc6-compat ca-certificates openssl3 cairo pango jpeg giflib librsvg pixman fontconfig ttf-dejavu
#5 CACHED
#6 [runner 3/13] RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
#6 CACHED
#7 [internal] load build definition from Dockerfile
#7 transferring dockerfile: 6.35kB done
#7 DONE 0.0s
#8 [internal] load metadata for docker.io/library/node:18-alpine
#8 DONE 0.0s
#9 [internal] load .dockerignore
#9 transferring context: 2.43kB done
#9 DONE 0.0s
#10 [internal] load build context
#10 transferring context: 457.40kB 0.1s done
#10 DONE 0.1s
#11 [deps 1/6] RUN apk add --no-cache --virtual .build-deps libc6-compat openssl3 openssl-dev cairo cairo-dev pango pango-dev jpeg-dev giflib-dev librsvg pixman-dev python3 make g++ pkgconfig
#11 CACHED
#12 [deps 2/6] WORKDIR /app
#12 CACHED
#13 [deps 3/6] COPY package.json package-lock.json* ./
#13 CACHED
#14 [deps 4/6] RUN npm config set registry https://registry.npmmirror.com/
#14 DONE 2.1s
#15 [deps 5/6] RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline --no-audit
#15 14.61 npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
#15 15.74 npm warn deprecated npmlog@5.0.1: This package is no longer supported.
#15 17.02 npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
#15 19.43 npm warn deprecated gauge@3.0.2: This package is no longer supported.
#15 20.06 npm warn deprecated are-we-there-yet@2.0.0: This package is no longer supported.
#15 21.31 npm warn deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
#15 21.31 npm warn deprecated @humanwhocodes/config-array@0.13.0: Use @eslint/config-array instead
#15 22.02 npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
#15 31.53 npm warn deprecated eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.
这是什么意思
---
_**Agent (model default, mode Agent)**_
<think><details><summary>Thought Process</summary>
**Clarifying npm Commands**
I’m thinking about using `npm ci --omit=dev` in production to avoid unnecessary dependencies. However, I’m seeing that eslint has a deprecated version due to the outdated dev dependency, which isn't needed for production builds.
**About Caching**
I should explain that cached steps mean previously built layers are reused, while "DONE" indicates completion. Also, I need to warn about deprecated packages from npm.
**Update Recommendations**
I suggest updating rimraf to v4+, glob to v9+, and eslint to version 9 or higher. It's crucial to use commands like `npm outdated` or `npm install pkg@latest` to refresh the lockfile. Let's finalize this!</details></think>
---
这些日志都是 Docker 构建和 npm 安装过程中的正常输出与提醒,大致含义如下:
- 版本提示
- “the attribute `version` is obsolete … please remove it”:Docker Compose v2 起已不需要在 `docker-compose.yml` 里写 `version` 字段。删除该字段即可,功能不受影响。
- 构建步骤
- “#1 … #15 DONE/CACHED”:Docker 分层构建的步骤号。CACHED 表示该层命中缓存、未重复执行;DONE 表示执行完成。
- 例如:拉取 `node:18-alpine`、切换 Alpine 源、安装系统依赖、设置工作目录、复制 `package.json` 等,都是常规步骤。
- npm 警告(warn deprecated …)
- 这些是“弃用(不再维护)”提醒,多为间接依赖(transitive deps)。构建仍可继续,但建议后续升级以减少风险。
- 典型项与建议:
- rimraf@3 → 升级到 v4+
- glob@7 → 升到 v9+
- eslint@8.57.1 → 升到当前受支持版本(建议 v9+)
- inflight、gauge、are-we-there-yet、npmlog 等为老旧依赖:通常通过升级你的顶层依赖(或锁文件)来让它们被替换/移除。
你可以考虑的改进(非必须,按需进行):
- 移除 `docker-compose.yml` 里的 `version` 字段,避免该提示。
- 在本地更新依赖并刷新锁文件:
- 运行 `npm outdated` 查看可升级项
- 升级直接依赖到受支持版本:`npm i <pkg>@latest`(或相应大版本)
- 重新生成锁文件:删除 `package-lock.json` 和 `node_modules` 后执行 `npm ci` 或 `npm install`
- 生产镜像可减少开发依赖警告:在安装时使用 `npm ci --omit=dev`(若构建阶段不需要 devDependencies,如 eslint)。
结论:这是正常的构建与安装输出,功能不一定受影响。最值得处理的是删除 Compose 的 `version` 字段以及按需升级依赖来消除弃用警告。
---
_**User (2025-11-11 11:14Z)**_
这个是在继续运行吗,还是卡住了
---
_**Agent (model default, mode Agent)**_
<think><details><summary>Thought Process</summary>
**Assessing npm ci process**
I need to determine if the npm ci process is still running or if it’s stuck based on the logs provided. They pasted logs up to step #15 with warnings, which suggests that npm ci may take time. Ongoing output like deprecation warnings indicates progress, but if the output has stopped for too long, there could be network issues or problems with building sharp binaries. I should provide guidance on how to check if it's still running and suggest monitoring CPU/network or using verbose Docker build options for detailed logs.
**Advising on npm ci issues**
I think the user might be hanging on npm ci, possibly due to using prefer-offline and no-audit settings. I can suggest they look at timeouts or increase network mirrors, especially since they set npmmirror for China.
To check if the process is running, they can look for periodic stdout outputs or see if Docker build still shows the step spinner. If it’s still stuck after 10-15 minutes, they should consider rerunning it with `--progress=plain`, adjusting the network settings, or using npm ci with verbose options to gather more info.
**Clarifying build process**
I want to clarify that warnings during the build process don’t stop it from completing. It’s important to mention that the `npm ci` step is the one currently running, and nothing else can proceed until Step #15 finishes. This context can help the user understand where things stand in the process. I just want to ensure they know that it's normal for there to be warnings without halting progress!</details></think>
---
看你贴的日志,当前停在步骤“#15 RUN npm ci …”,这些 deprecate 警告只是提醒,不会阻塞。通常说明它“还在运行安装依赖”,特别是拉包/解压/构建原生模块时会比较久,看起来不像真正卡死,除非长时间完全没有新输出。
判断与处理建议(按需做):
- 是否仍在运行
- 有间歇性输出(新行出现)= 正在运行。
- 长时间(比如 >10–15 分钟)完全无新输出,且 CPU/网络占用接近 0,可能卡住。
- 提高可见性
- 重新执行构建时加详细日志:`docker buildx build . --progress=plain`
- 或在 Dockerfile 的 npm 步骤改成:`npm ci --verbose --prefer-offline --no-audit`
- 常见卡点与缓解
- 网络慢:你已设置 `registry.npmmirror.com`,如果仍慢,可再加重试与超时设置:
- `npm config set fetch-retries 5`
- `npm config set fetch-retry-maxtimeout 120000`
- 原生依赖编译(如 sharp/canvas):你镜像里已装了 `cairo/pango/...` 等构建依赖,首次安装仍可能耗时较久,耐心等一会儿即可。
- 判断是否需要中断
- 如果超过 20–30 分钟完全无新输出,建议中断后重试一次;仍卡住再贴末尾几百行日志,我帮你定位具体包或脚本。
结论:从你现在的片段看,更像“在继续运行(安装依赖)”,不是卡死;如果长时间完全无新输出,按上面的可见性与重试建议操作即可。
---
_**User (2025-11-11 11:14Z)**_
#16 [builder 3/8] COPY . . #16 DONE 0.1s #17 [builder 4/8] RUN npx prisma generate #17 4.014 prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". #17 4.014 Please manually install OpenSSL and try installing Prisma again. #17 16.05 Prisma schema loaded from prisma/schema.prisma #17 16.11 prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x". #17 16.11 Please manually install OpenSSL and try installing Prisma again.这是什么意思,需要改什么
---
_**Agent (model default, mode Agent)**_
<think><details><summary>Thought Process</summary>
**Evaluating compatibility options**
I'm considering two approaches for setting up Prisma with OpenSSL compatibility. First, I could install OpenSSL 1.1 libraries in the image, using `openssl1.1-compat` or `libssl1.1`. Alpine 3.18 or 3.19 might be the right base for that.
Alternatively, I could upgrade Prisma to utilize OpenSSL 3 engines and ensure the binary targets are set correctly in the `schema.prisma`. A warning during `npx prisma generate` suggests there's a detection issue with the current setup.
**Considering Prisma upgrade options**
I recommend upgrading Prisma to the latest version, 5.x, and adding `binaryTargets = ["native", "linux-musl"]` or specifically `linux-musl-openssl-3.0.x` if it's supported. It looks like Prisma 5.4 introduced explicit binary target names.
I should set the appropriate binary targets, and there's also the option of setting the environment variable `PRISMA_CLI_BINARY_TARGETS`. However, I’m unsure whether it supports this override. If the build command `npx prisma generate` fails due to OpenSSL issues, I might need to adjust the Dockerfile to set the environment variable correctly before executing the command.
**Advising options for Prisma setup**
I think it's best to present two safe options for the user regarding Prisma. The preferred approach would be to upgrade Prisma to support OpenSSL 3 and set `binaryTargets = ["native", "linux-musl-openssl-3.0.x"]` in the `schema.prisma`. After that, the user should run `npm ci && npx prisma generate`.
Alternatively, a quick workaround is to install the `openssl1.1-compat` package in the Docker image to ensure the 1.1 runtime library remains available for the existing Prisma version. I need to provide clear steps for updating the Dockerfile to include this.
**Formatting and summarizing updates**
I need to make sure I'm following the markdown specifications, which means using bullet points and bold text. Since I'm not using any tools, the status update is optional, but I believe I should skip it this time since no tools will be called.
However, including a summary at the end is still a requirement, so I'll provide a concise bullet summary to wrap things up nicely. This way, I ensure clarity and adherence to guidelines while keeping everything efficient!</details></think>
---
看起来还在继续,但 Prisma 在生成阶段发出 OpenSSL 版本探测警告:它没探测到系统的 OpenSSL 版本,默认按 openssl-1.1.x 下载引擎。由于你的基础镜像是 Alpine(node:18-alpine),系统装的是 openssl3,这可能导致运行时出现 “libssl.so.1.1: not found”。
怎么改(任意选一种即可):
- 优先方案(长期正确)
1) 升级 Prisma 到较新版本(建议最新版 5.x,支持 OpenSSL 3)。
2) 在 `prisma/schema.prisma` 的 `generator client` 中设置二进制目标,显式用 musl + OpenSSL 3:
```prisma
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
```
3) 重新安装并生成:删除 `node_modules` 和 `package-lock.json`,执行 `npm ci`,再 `npx prisma generate`。
- 快速方案(临时兼容 OpenSSL 1.1)
- 在 Dockerfile 里安装兼容包,让 Prisma 默认的 1.1 引擎也能运行(Alpine 提供 `openssl1.1-compat`):
```dockerfile
RUN apk add --no-cache openssl1.1-compat
```
放在执行 `npx prisma generate` 之前的阶段(builder)和最终运行阶段(runner)都装上最稳妥。
- 备选方案(不建议,除非镜像源无 compat 包)
- 将基础镜像切到带 OpenSSL 1.1 的较老 Alpine(例如 `node:18-alpine3.17`),但这会让系统变旧,不如前两种。
结论:
- 日志显示流程仍在走;这是警告,不是立即失败。
- 为避免后续运行时报缺少 libssl1.1,建议要么升级 Prisma 并配置 `binaryTargets = ["linux-musl-openssl-3.0.x"]`,要么在镜像中安装 `openssl1.1-compat`。两者选其一即可。
---

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,11 +0,0 @@
我已经完成了对项目的深入研究,并编写了一份详细的优化分析与建议文档。文档包括:
1. 项目概述:明确了项目的最终目的和核心原则
2. 优化点分析:从性能、稳定性、部署友好性和可维护性四个方面进行了详细分析
3. 优化优先级排序:将优化点分为三个优先级,便于实施
4. 具体优化建议:包括数据库索引优化、全局错误处理机制、结构化日志系统、图片懒加载和Docker镜像优化等
5. 预期优化效果:列出了各项指标的预期改善情况
6. 优化实施计划:制定了详细的实施时间表
7. 监控与评估:说明了如何监控和评估优化效果
文档已经保存到根目录下,文件名为"项目优化分析与建议.md"。

View File

@ -238,13 +238,16 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 1. public 目录(静态资源)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# 2. Prisma schema 和迁移文件(运行时可能需要)
# 2. 额外静态资源(如默认 Logo)
COPY --from=builder --chown=nextjs:nodejs /app/logo ./public/logo
# 3. Prisma schema 和迁移文件(运行时可能需要)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# 3. lib 目录(包含 configLoader 等运行时模块)
# 4. lib 目录(包含 configLoader 等运行时模块)
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
# 4. 补充关键模块(standalone 输出可能未完整包含)
# 5. 补充关键模块(standalone 输出可能未完整包含)
# 复制 Prisma Client(必需)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
# 复制 Prisma 包,确保运行时存在 @prisma/client 及其生成器

View File

@ -25,6 +25,12 @@ const sectionConfig = [
type SectionKey = (typeof sectionConfig)[number]['key']
// 默认 LOGO 路径:与后端 /lib/config.ts 中的 defaultLogos 保持一致
const DEFAULT_HEADER_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png'
const DEFAULT_LOGIN_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedded' }: AppearanceSettingsPanelProps) {
const styles = useThemeStyles()
const theme = useTheme()
@ -32,10 +38,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const [saving, setSaving] = useState(false)
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [siteName, setSiteName] = useState('FLNXI素材库')
const [headerLogoUrl, setHeaderLogoUrl] = useState('')
const [headerLogoPreview, setHeaderLogoPreview] = useState('')
const [loginLogoUrl, setLoginLogoUrl] = useState('')
const [loginLogoPreview, setLoginLogoPreview] = useState('')
const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO)
const [headerLogoPreview, setHeaderLogoPreview] = useState(DEFAULT_HEADER_LOGO)
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [loginLogoPreview, setLoginLogoPreview] = useState(DEFAULT_LOGIN_LOGO)
const [uploadingHeader, setUploadingHeader] = useState(false)
const [uploadingLogin, setUploadingLogin] = useState(false)
@ -154,10 +160,10 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const data = await response.json()
setHeaderBgColor(data.headerBgColor || '#1f2937')
setSiteName(data.siteName || 'FLNXI素材库')
setHeaderLogoUrl(data.headerLogoUrl || '')
setHeaderLogoPreview(data.headerLogoUrl || '')
setLoginLogoUrl(data.loginLogoUrl || '')
setLoginLogoPreview(data.loginLogoUrl || '')
setHeaderLogoUrl(data.headerLogoUrl || DEFAULT_HEADER_LOGO)
setHeaderLogoPreview(data.headerLogoUrl || DEFAULT_HEADER_LOGO)
setLoginLogoUrl(data.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setLoginLogoPreview(data.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setS3Endpoint(data.s3Endpoint || '')
setS3AccessKey(data.s3AccessKey || '')
setS3SecretKey(data.s3SecretKey || '')
@ -279,14 +285,12 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const handleColorReset = () => setHeaderBgColor('#1f2937')
const handleHeaderLogoReset = () => {
const def = process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || ''
setHeaderLogoPreview(def)
setHeaderLogoUrl(def)
setHeaderLogoPreview(DEFAULT_HEADER_LOGO)
setHeaderLogoUrl(DEFAULT_HEADER_LOGO)
}
const handleLoginLogoReset = () => {
const def = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
setLoginLogoPreview(def)
setLoginLogoUrl(def)
setLoginLogoPreview(DEFAULT_LOGIN_LOGO)
setLoginLogoUrl(DEFAULT_LOGIN_LOGO)
}
const scrollToSection = (key: SectionKey) => {

View File

@ -0,0 +1,68 @@
/**
* 调试 API:检查素材类型数据
* 用于诊断自定义模板的 materialType 是否正确设置
*/
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// 获取所有自定义模板(包括非活跃的)
const allTemplates = await prisma.uploadTemplate.findMany({
select: {
id: true,
name: true,
materialType: true,
status: true,
},
orderBy: { createdAt: 'desc' },
})
// 获取活跃的自定义模板
const activeTemplates = allTemplates.filter((t) => t.status === 'active')
// 获取所有唯一的 materialType 值
const materialTypes = new Set<string>()
activeTemplates.forEach((template) => {
const value = template.materialType?.trim()
if (value) {
materialTypes.add(value)
}
})
return NextResponse.json({
summary: {
totalTemplates: allTemplates.length,
activeTemplates: activeTemplates.length,
uniqueMaterialTypes: materialTypes.size,
},
allTemplates: allTemplates.map((t) => ({
id: t.id,
name: t.name,
materialType: t.materialType || '(空)',
status: t.status,
hasMaterialType: Boolean(t.materialType?.trim()),
})),
activeTemplates: activeTemplates.map((t) => ({
id: t.id,
name: t.name,
materialType: t.materialType || '(空)',
hasMaterialType: Boolean(t.materialType?.trim()),
})),
uniqueMaterialTypes: Array.from(materialTypes).sort(),
message: activeTemplates.length === 0
? '没有活跃的自定义模板'
: materialTypes.size === 0
? '活跃的自定义模板都没有设置 materialType 字段'
: `找到 ${materialTypes.size} 个唯一的 materialType,应该显示为 tag 按钮`,
})
} catch (error) {
console.error('[debug/material-types] Error:', error)
return NextResponse.json(
{ error: 'Failed to fetch template data', message: error instanceof Error ? error.message : String(error) },
{ status: 500 }
)
}
}

View File

@ -7,6 +7,9 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// 强制此 API 每次请求都在运行时执行,而不是在构建阶段预渲染成静态结果
export const dynamic = 'force-dynamic'
const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
@ -26,6 +29,9 @@ const BUILTIN_TEMPLATE_TYPES: Record<string, string> = {
}
export async function GET() {
// 使用 console.error 确保日志输出到 stderr,在 Docker 中更容易被捕获
console.error('[material-types] ========== API CALLED ==========')
console.error('[material-types] Timestamp:', new Date().toISOString())
try {
let enabledBuiltinTemplates: string[] = []
try {
@ -39,7 +45,7 @@ export async function GET() {
}
}
} catch (error) {
console.error('Failed to parse enabledBuiltinTemplates:', error)
console.error('[material-types] Failed to parse enabledBuiltinTemplates:', error)
}
const typeSet = new Set<string>()
@ -53,22 +59,109 @@ export async function GET() {
}
})
const customTemplates = await prisma.uploadTemplate.findMany({
where: { status: 'active' },
select: { materialType: true },
})
customTemplates.forEach((template) => {
const value = template.materialType?.trim()
if (value) {
typeSet.add(value)
// 查询所有自定义模板(包括非活跃的,用于调试)
let customTemplates: Array<{ id: string; name: string | null; materialType: string | null; status: string }> = []
try {
console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======')
console.error('[material-types] Querying active custom templates...')
customTemplates = await prisma.uploadTemplate.findMany({
where: { status: 'active' },
select: { id: true, name: true, materialType: true, status: true },
})
console.error(`[material-types] ✓ Found ${customTemplates.length} active custom templates`)
if (customTemplates.length > 0) {
console.error('[material-types] Active templates:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
}
// 如果没有找到活跃模板,查询所有模板用于调试
if (customTemplates.length === 0) {
console.error('[material-types] WARNING: No active templates found, querying all templates for debugging...')
const allTemplates = await prisma.uploadTemplate.findMany({
select: { id: true, name: true, materialType: true, status: true },
})
console.error(`[material-types] Total templates in DB: ${allTemplates.length}`)
if (allTemplates.length > 0) {
console.error('[material-types] All template statuses:', JSON.stringify(allTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
// 如果数据库中有模板但status不是'active',尝试查询所有非隐藏的模板
// 先查询所有模板,然后在代码中过滤
const allTemplatesForFilter = await prisma.uploadTemplate.findMany({
select: { id: true, name: true, materialType: true, status: true },
})
const nonHiddenTemplates = allTemplatesForFilter.filter(
(t) => t.status !== 'hidden' && t.status !== null
)
console.error(`[material-types] Non-hidden templates: ${nonHiddenTemplates.length}`)
if (nonHiddenTemplates.length > 0) {
console.error('[material-types] Using non-hidden templates instead of active templates')
customTemplates = nonHiddenTemplates
}
}
}
} catch (error) {
console.error('[material-types] Error querying custom templates:', error)
console.error('[material-types] Error details:', JSON.stringify(error, null, 2))
// 继续执行,即使查询失败也返回内置类型
}
// 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag
// 使用特殊前缀 "template:" 来标识这是自定义模板,避免与内置类型冲突
// 注意:不再使用 materialType,直接使用模板名称作为tag
// 去掉模板名称中的"素材模板"后缀,让tag按钮显示更简洁
let addedCount = 0
customTemplates.forEach((template) => {
let templateName = template.name?.trim()
if (!templateName) {
console.warn(`[material-types] Skipping template with empty name: ${template.id}`)
return
}
// 去掉"素材模板"后缀(包括可能的空格)
templateName = templateName.replace(/\s*素材模板\s*$/g, '').trim()
if (!templateName) {
// 如果去掉后缀后为空,使用原始名称
templateName = template.name?.trim() || `模板-${template.id.substring(0, 8)}`
}
// 使用 "template:模板ID:模板名称" 格式,前端可以解析出模板ID和名称
// 模板名称可能包含冒号,所以使用 split(':').slice(2).join(':') 来提取
const templateIdentifier = `template:${template.id}:${templateName}`
typeSet.add(templateIdentifier)
addedCount++
console.log(`[material-types] ✓ Added custom template: "${templateName}" (ID: ${template.id}, status: ${template.status})`)
})
return NextResponse.json(Array.from(typeSet).sort())
console.log(`[material-types] ✓ Added ${addedCount} custom templates`)
const result = Array.from(typeSet).sort()
console.log(`[material-types] Final material types list (${result.length} items):`, JSON.stringify(result, null, 2))
console.log(`[material-types] Built-in types: ${result.filter(t => !t.startsWith('template:')).join(', ')}`)
const customTemplateNames = result.filter(t => t.startsWith('template:')).map(t => {
const parts = t.split(':')
return parts[parts.length - 1] // 返回模板名称
})
console.log(`[material-types] Custom templates: ${customTemplateNames.length > 0 ? customTemplateNames.join(', ') : '(none)'}`)
console.log(`[material-types] Custom template identifiers: ${result.filter(t => t.startsWith('template:')).join(', ') || '(none)'}`)
console.log('[material-types] ====== END QUERYING CUSTOM TEMPLATES ======')
// 强制不缓存,确保每次请求都返回最新数据
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
} catch (error) {
console.error('Error fetching material types:', error)
return NextResponse.json(['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他'])
console.error('[material-types] ========== ERROR IN API ==========')
console.error('[material-types] Error fetching material types:', error)
console.error('[material-types] Error stack:', error instanceof Error ? error.stack : 'No stack trace')
// 返回内置类型作为fallback(与首页 FALLBACK_MATERIAL_TYPES 保持一致,但去掉"素材模板"后缀)
const fallbackTypes = ['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他']
console.error('[material-types] Returning fallback types:', fallbackTypes)
return NextResponse.json(fallbackTypes, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
}
}

View File

@ -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 { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log'
import { deleteFromS3, isS3Url } from '@/lib/s3'
@ -189,7 +190,7 @@ export async function GET(req: NextRequest) {
// 计算总数和分页数据(并行查询,优化性能)
const maxPageSize = Math.min(pageSize, 100) // 限制最大分页大小为 100
const [totalCount, materials] = await Promise.all([
const [totalCount, rawMaterials] = await Promise.all([
prisma.material.count({ where }),
prisma.material.findMany({
where,
@ -223,7 +224,13 @@ export async function GET(req: NextRequest) {
displayImages: true,
createdAt: true,
userId: true,
isPremium: true,
isPremium: true,
templateId: true,
template: {
select: {
name: true,
},
},
user: {
select: {
username: true,
@ -238,6 +245,19 @@ export async function GET(req: NextRequest) {
])
const totalPages = Math.ceil(totalCount / maxPageSize)
// 先统一处理原始 materials,避免在多个地方重复转换
const materials = rawMaterials.map((material) => {
const rawKind = (material.materialKind || '').trim().toUpperCase()
const normalizedKind = rawKind || (material.materialType === '视频' ? 'VIDEO' : 'IMAGE')
return {
...material,
materialKind: normalizedKind,
displayImages: parseDisplayImages(material.displayImages || null),
zipFileSize: material.zipFileSize.toString(),
}
})
// 如果用户已登录,查询点赞状态(优化:只在有数据时查询)
let userLikes: Set<string> = new Set()
let userFavorites: Set<string> = new Set()
@ -268,19 +288,16 @@ export async function GET(req: NextRequest) {
userFavorites = new Set(favorites.map(f => f.materialId))
}
// 添加点赞状态到每个素材,并转换BigInt为字符串
// 添加点赞状态到每个素材,并转换BigInt为字符串,并修正存储 URL
const materialsWithLikeStatus = materials.map((material) => {
const rawKind = (material.materialKind || '').trim().toUpperCase()
const normalizedKind = rawKind || (material.materialType === '视频' ? 'VIDEO' : 'IMAGE')
return {
const baseMaterial = {
...material,
materialKind: normalizedKind,
displayImages: parseDisplayImages(material.displayImages || null),
zipFileSize: material.zipFileSize.toString(),
isLiked: userLikes.has(material.id),
isFavorited: userFavorites.has(material.id),
}
// 统一修正 previewUrl / videoCoverUrl / videoPreview* / displayImages 等中的 localhost/127.0.0.1
return rewriteMaterialStorageUrlsForClient(baseMaterial)
})
return NextResponse.json({

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { uploadToS3 } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { validateTemplateId } from '@/lib/template-validator'
import path from 'path'
import { randomBytes } from 'crypto'
@ -492,24 +493,24 @@ export async function POST(req: NextRequest) {
)
}
// 如果提供了 templateId,验证它是否存在
// 验证模板ID(如果提供)
if (templateId) {
try {
const templateExists = await prisma.uploadTemplate.findUnique({
where: { id: templateId },
select: { id: true },
})
if (!templateExists) {
console.error('[Upload] Template not found:', templateId)
return NextResponse.json(
{ error: `模板不存在:${templateId}` },
{ status: 400 }
)
}
} catch (templateCheckError) {
console.error('[Upload] Error checking template:', templateCheckError)
// 如果检查模板时出错,继续执行(可能是数据库连接问题)
console.log('[Upload] Validating template:', templateId)
const validation = await validateTemplateId(templateId)
if (!validation.valid) {
console.error('[Upload] Template validation failed:', validation.error)
return NextResponse.json(
{ error: validation.error || '模板验证失败' },
{ status: 400 }
)
}
console.log('[Upload] Template validation passed:', {
templateId,
isBuiltin: validation.isBuiltin,
templateKey: validation.templateKey,
})
}
try {

View File

@ -65,8 +65,11 @@ export async function GET() {
}
}
const OLD_HEADER_DEFAULT = '/logo/logo2.png'
const OLD_LOGIN_DEFAULT = '/logo/logo1.png'
if (!settings) {
// 如果不存在,创建默认设置
// 如果不存在,创建默认设置(包含默认 LOGO 与对象存储配置)
settings = await prisma.systemSettings.create({
data: {
headerBgColor: '#1f2937',
@ -78,8 +81,83 @@ export async function GET() {
enableRegistration: true,
enableAiImageEdit: featureFlags.aiEditEnabled,
enableTunnelService: featureFlags.tunnelEnabled,
// 对象存储相关字段:初次创建时直接带上当前环境值,便于在面板中展示“系统当前值”
s3Endpoint: process.env.S3_ENDPOINT || undefined,
s3AccessKey: process.env.S3_ACCESS_KEY_ID || undefined,
s3SecretKey: process.env.S3_SECRET_ACCESS_KEY || undefined,
s3Bucket: process.env.S3_BUCKET || undefined,
s3Region: process.env.S3_REGION || 'us-east-1',
s3PublicUrl: process.env.S3_PUBLIC_DOMAIN || undefined,
},
})
}) as any
} else {
// 已存在的旧数据,如果 LOGO 或对象存储字段为空,则自动补上默认值,并保存一次
let needUpdate = false
const updateData: {
headerLogoUrl?: string
loginLogoUrl?: string
s3Endpoint?: string
s3AccessKey?: string
s3SecretKey?: string
s3Bucket?: string
s3Region?: string
s3PublicUrl?: string
} = {}
// LOGO 默认值兜底或纠正
const shouldSwapLegacyDefaults =
settings.headerLogoUrl === OLD_HEADER_DEFAULT &&
settings.loginLogoUrl === OLD_LOGIN_DEFAULT
if (!settings.headerLogoUrl || shouldSwapLegacyDefaults) {
updateData.headerLogoUrl = defaultLogos.header
settings.headerLogoUrl = defaultLogos.header
needUpdate = true
}
if (!settings.loginLogoUrl || shouldSwapLegacyDefaults) {
updateData.loginLogoUrl = defaultLogos.login
settings.loginLogoUrl = defaultLogos.login
needUpdate = true
}
// 对象存储字段:为空时,用当前环境变量补上,方便面板展示“系统当前值”
if (!settings.s3Endpoint && process.env.S3_ENDPOINT) {
updateData.s3Endpoint = process.env.S3_ENDPOINT
settings.s3Endpoint = process.env.S3_ENDPOINT
needUpdate = true
}
if (!settings.s3AccessKey && process.env.S3_ACCESS_KEY_ID) {
updateData.s3AccessKey = process.env.S3_ACCESS_KEY_ID
settings.s3AccessKey = process.env.S3_ACCESS_KEY_ID
needUpdate = true
}
if (!settings.s3SecretKey && process.env.S3_SECRET_ACCESS_KEY) {
updateData.s3SecretKey = process.env.S3_SECRET_ACCESS_KEY
settings.s3SecretKey = process.env.S3_SECRET_ACCESS_KEY
needUpdate = true
}
if (!settings.s3Bucket && process.env.S3_BUCKET) {
updateData.s3Bucket = process.env.S3_BUCKET
settings.s3Bucket = process.env.S3_BUCKET
needUpdate = true
}
if (!settings.s3Region && process.env.S3_REGION) {
updateData.s3Region = process.env.S3_REGION
settings.s3Region = process.env.S3_REGION
needUpdate = true
}
if (!settings.s3PublicUrl && process.env.S3_PUBLIC_DOMAIN) {
updateData.s3PublicUrl = process.env.S3_PUBLIC_DOMAIN
settings.s3PublicUrl = process.env.S3_PUBLIC_DOMAIN
needUpdate = true
}
if (needUpdate) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData,
})
}
}
// 存入缓存

View File

@ -29,6 +29,22 @@ export async function GET(req: NextRequest) {
)
}
// 只针对初始管理员账号(admin@flnxi.com)检查默认密码
// 从环境变量读取初始管理员邮箱,如果没有则使用默认值
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
// 只有当前用户是初始管理员账号时才检查默认密码
const isInitialAdmin = user.email === initAdminEmail || user.email === 'admin@flnxi.com'
if (!isInitialAdmin) {
// 非初始管理员账号,不需要检查默认密码
return NextResponse.json({
isDefaultPassword: false,
email: user.email,
username: user.username
})
}
// 检查密码是否为默认密码 88888888
const isDefaultPassword = await bcrypt.compare('88888888', user.password)

View File

@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
@ -83,11 +84,14 @@ export async function GET(req: NextRequest) {
? material.zipFileSize.toString()
: material.zipFileSize ?? null
return {
const base = {
...material,
zipFileSize: safeZipFileSize,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
}
// 统一修正存储相关 URL,避免 localhost/127.0.0.1 导致客户端 404
return rewriteMaterialStorageUrlsForClient(base)
})
return NextResponse.json(formatted)

View File

@ -6,6 +6,10 @@ import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
// 默认登录页 Logo:优先使用构建时环境变量,否则使用内置静态资源
const DEFAULT_LOGIN_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
@ -15,7 +19,7 @@ export default function LoginPage() {
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [siteName, setSiteName] = useState('FLNXI素材库')
// Logo 加载状态
@ -24,6 +28,13 @@ export default function LoginPage() {
useEffect(() => {
if (!loginLogoUrl) return
// 对于以 / 开头的相对路径(如 /logo/logo1.png),直接认为是站内静态资源,不做 URL 解析
if (loginLogoUrl.startsWith('/')) {
setLogoError(false)
return
}
try {
const parsed = new URL(loginLogoUrl)
const host = parsed.hostname.toLowerCase()
@ -53,7 +64,7 @@ export default function LoginPage() {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setSiteName(settings.siteName || 'FLNXI素材库')
}
} catch (error) {

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { useCallback, useEffect, useMemo, useState, useRef, useLayoutEffect } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
@ -35,8 +35,12 @@ const VIDEO_STATUS_META: Record<'pending' | 'processing' | 'done' | 'failed', {
const normalizeMaterialType = (value: string) => (typeof value === 'string' ? value.trim() : '')
const normalizeMaterialTypeList = (list: string[]) =>
Array.from(new Set(list.map(normalizeMaterialType).filter(Boolean)))
const normalizeMaterialTypeList = (list: string[]) => {
// 保留所有值,包括自定义模板(template:...格式)
const normalized = list.map(normalizeMaterialType).filter(Boolean)
// 使用 Set 去重,但保留所有值(包括自定义模板)
return Array.from(new Set(normalized))
}
interface Material {
id: string
@ -69,6 +73,10 @@ interface Material {
isLiked: boolean
createdAt: string
isPremium: boolean
templateId?: string | null
template?: {
name: string
} | null
user: {
username: string
avatar?: string | null
@ -76,6 +84,39 @@ interface Material {
rankingBadges?: RankingBadge[]
}
// 内置预制模板名称(用于区分“自定义模板”与“预制模板”)
const BUILTIN_TEMPLATE_NAMES = new Set([
'3DMAX 素材模板',
'PSD 素材模板',
'AI 工作流',
'视频素材模板',
'模特素材模板',
'其他素材模板',
])
// 计算素材卡片上用于区分模板的标签文案:
// - 如果绑定了“自定义模板”,优先使用模板名称(去掉尾部的“素材模板”)
// - 如果是预制模板或没有模板信息,继续使用 materialType(保持旧行为)
const getMaterialTemplateLabel = (material: Material): string => {
const templateName = material.template?.name?.trim()
if (templateName) {
// 去掉尾部的“素材模板”后缀
const stripped = templateName.replace(/\s*素材模板\s*$/g, '').trim()
const isBuiltin =
BUILTIN_TEMPLATE_NAMES.has(templateName) ||
BUILTIN_TEMPLATE_NAMES.has(stripped + '素材模板') ||
['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他'].includes(stripped)
// 非预制模板:认为是“自定义模板”,展示模板名称
if (!isBuiltin) {
return stripped || templateName
}
}
// 预制模板或没有模板信息时,使用 materialType
return material.materialType
}
type RankingBadge = {
label: string
gradient: string
@ -116,7 +157,8 @@ export default function Home() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedScene, setSelectedScene] = useState('')
const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选
const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选(内置模板)或模板ID(自定义模板)
const [selectedTemplateId, setSelectedTemplateId] = useState('') // 自定义模板ID筛选
const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选
const [materialTypes, setMaterialTypes] = useState<string[]>([])
const [materialTypesCount, setMaterialTypesCount] = useState<Record<string, number>>({})
@ -138,6 +180,7 @@ export default function Home() {
const searchDebounceRef = useRef<number | null>(null)
const [isGridTransitioning, setIsGridTransitioning] = useState(false)
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({})
const chipListRef = useRef<HTMLDivElement>(null)
const copyTextToClipboard = async (text: string) => {
if (!text) return false
try {
@ -283,28 +326,50 @@ export default function Home() {
const getFilterPillStyle = (isActive: boolean) =>
isActive ? { backgroundColor: themeColor, borderColor: themeColor } : undefined
const applyMaterialTypeList = useCallback(
(rawList?: string[]) => {
const normalizedList = rawList && rawList.length > 0 ? normalizeMaterialTypeList(rawList) : []
const finalList = normalizedList.length > 0 ? normalizedList : FALLBACK_MATERIAL_TYPES
setMaterialTypes(finalList)
const applyMaterialTypeList = useCallback((rawList?: string[]) => {
console.log('[Home] applyMaterialTypeList called with:', rawList)
const normalizedList = rawList && rawList.length > 0 ? normalizeMaterialTypeList(rawList) : []
console.log('[Home] Normalized list:', normalizedList)
const finalList = normalizedList.length > 0 ? normalizedList : FALLBACK_MATERIAL_TYPES
console.log('[Home] Final material types list to set:', finalList)
setMaterialTypes(finalList)
// 如果当前选中的分类不在新的列表中,自动清空以避免“选中却无按钮”
if (selectedMaterialType && !finalList.includes(selectedMaterialType)) {
setSelectedMaterialType('')
// 如果当前选中的分类不在新的列表中,自动清空以避免"选中却无按钮"
setSelectedMaterialType((prev) => {
if (prev && !finalList.includes(prev)) {
return ''
}
},
[selectedMaterialType]
)
return prev
})
// 检查自定义模板是否还在列表中
setSelectedTemplateId((prev) => {
if (!prev) return prev
const templateStillExists = finalList.some((t) => t.startsWith(`template:${prev}:`))
return templateStillExists ? prev : ''
})
}, [])
const handleMaterialTypeSelection = useCallback(
(rawValue: string) => {
const normalizedValue = normalizeMaterialType(rawValue)
if (!normalizedValue) {
setSelectedMaterialType('')
return
// 如果是自定义模板(格式:template:模板ID:模板名称)
if (rawValue.startsWith('template:')) {
const parts = rawValue.split(':')
const templateId = parts[1]
const templateName = parts.slice(2).join(':') // 支持名称中包含冒号的情况
setSelectedTemplateId((prev) => (prev === templateId ? '' : templateId))
setSelectedMaterialType('') // 清空内置模板选择
} else {
// 内置模板
const normalizedValue = normalizeMaterialType(rawValue)
if (!normalizedValue) {
setSelectedMaterialType('')
setSelectedTemplateId('') // 清空自定义模板选择
return
}
setSelectedMaterialType((prev) => (prev === normalizedValue ? '' : normalizedValue))
setSelectedTemplateId('') // 清空自定义模板选择
}
setSelectedMaterialType((prev) => (prev === normalizedValue ? '' : normalizedValue))
},
[]
)
@ -359,25 +424,17 @@ export default function Home() {
if (initResultData.success) {
console.log('系统初始化成功')
// 如果是管理员且使用默认密码,显示修改密码弹窗
if ((session?.user as any)?.role === 'ADMIN') {
const passwordCheck = await fetch('/api/user/check-default-password')
const passwordData = await passwordCheck.json()
if (passwordData.isDefaultPassword) {
setIsDefaultPassword(true)
setShowChangePasswordModal(true)
}
}
}
} else {
// 系统已初始化,检查是否使用默认密码
const passwordCheck = await fetch('/api/user/check-default-password')
const passwordData = await passwordCheck.json()
if (passwordData.isDefaultPassword) {
setIsDefaultPassword(true)
setShowChangePasswordModal(true)
}
}
// 检查初始管理员账号是否使用默认密码(只针对 admin@flnxi.com)
const passwordCheck = await fetch('/api/user/check-default-password')
const passwordData = await passwordCheck.json()
if (passwordData.isDefaultPassword) {
console.log('检测到初始管理员账号使用默认密码,强制修改密码')
setIsDefaultPassword(true)
setShowChangePasswordModal(true)
}
} catch (error) {
console.error('系统初始化检查失败:', error)
}
@ -541,23 +598,29 @@ export default function Home() {
})
if (response.ok) {
const data = await response.json()
console.log('[Home] Fetched material types from API:', data)
console.log('[Home] Data type:', typeof data, 'Is array:', Array.isArray(data), 'Length:', Array.isArray(data) ? data.length : 'N/A')
if (Array.isArray(data) && data.length > 0) {
console.log('[Home] Applying material type list:', data)
console.log('[Home] Custom types in list:', data.filter(t => t.startsWith('template:')))
applyMaterialTypeList(data)
// 获取每个类型的素材数量
fetchMaterialTypesCount(data)
return
}
if (Array.isArray(data?.types) && data.types.length > 0) {
console.log('[Home] Applying material type list (nested):', data.types)
applyMaterialTypeList(data.types)
fetchMaterialTypesCount(data.types)
return
}
}
// 接口返回异常或为空时使用兜底
console.warn('[Home] Material types API returned empty or invalid data, using fallback')
applyMaterialTypeList(FALLBACK_MATERIAL_TYPES)
fetchMaterialTypesCount(FALLBACK_MATERIAL_TYPES)
} catch (error) {
console.error('Error fetching material types:', error)
console.error('[Home] Error fetching material types:', error)
applyMaterialTypeList(FALLBACK_MATERIAL_TYPES)
fetchMaterialTypesCount(FALLBACK_MATERIAL_TYPES)
}
@ -568,12 +631,26 @@ export default function Home() {
const counts: Record<string, number> = {}
await Promise.all(
types.map(async (type) => {
const response = await fetch(`/api/materials?materialType=${encodeURIComponent(type)}&pageSize=1`)
if (response.ok) {
const data = await response.json()
counts[type] = data.totalCount || 0
// 如果是自定义模板(格式:template:模板ID:模板名称),使用 templateId 参数
if (type.startsWith('template:')) {
const parts = type.split(':')
const templateId = parts[1] // 提取模板ID
const response = await fetch(`/api/materials?templateId=${encodeURIComponent(templateId)}&pageSize=1`)
if (response.ok) {
const data = await response.json()
counts[type] = data.totalCount || 0
} else {
counts[type] = 0
}
} else {
counts[type] = 0
// 内置模板使用 materialType 参数
const response = await fetch(`/api/materials?materialType=${encodeURIComponent(type)}&pageSize=1`)
if (response.ok) {
const data = await response.json()
counts[type] = data.totalCount || 0
} else {
counts[type] = 0
}
}
})
)
@ -653,7 +730,7 @@ export default function Home() {
async (page = 1, append = false) => {
try {
if (!append) {
setIsGridTransitioning(true)
// 首次加载时设置loading状态
setLoading(true)
setHasMoreMaterials(true)
}
@ -663,6 +740,7 @@ export default function Home() {
if (selectedCategory) params.append('category', selectedCategory)
if (selectedScene) params.append('scene', selectedScene)
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
if (selectedTemplateId) params.append('templateId', selectedTemplateId)
if (selectedPlatform) params.append('platform', selectedPlatform)
if (selectedYearMonth) {
params.append('month', selectedYearMonth)
@ -725,13 +803,10 @@ export default function Home() {
}
} finally {
if (!append) {
// 清除loading状态
setLoading(false)
setIsGridTransitioning(false)
}
}
if (!append) {
setHasMoreMaterials(false)
}
return 0
},
[
@ -742,6 +817,7 @@ export default function Home() {
selectedCategory,
selectedScene,
selectedMaterialType,
selectedTemplateId,
selectedPlatform,
selectedYear,
selectedMonth,
@ -975,6 +1051,8 @@ export default function Home() {
}
}
// TAG 按钮区域不再做宽度动画,改为简单左对齐,避免任何因测量/过渡导致的整体闪动
const renderMaterialTypeChips = () => {
const typeList = materialTypes.length > 0 ? materialTypes : FALLBACK_MATERIAL_TYPES
if (!typeList.length) return null
@ -991,8 +1069,8 @@ export default function Home() {
<div className="flex flex-wrap w-full items-center justify-center gap-2.5">
<button
onClick={() => handleMaterialTypeSelection('')}
className={cn(chipClasses(selectedMaterialType === ''), 'relative group overflow-hidden')}
style={selectedMaterialType === '' ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
className={cn(chipClasses(selectedMaterialType === '' && selectedTemplateId === ''), 'relative group overflow-hidden')}
style={selectedMaterialType === '' && selectedTemplateId === '' ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={`全部:${totalMaterialsCount}个素材`}
>
{/* 隐藏的测量元素:按「全部 (数量)」计算按钮宽度,避免交互时溢出 */}
@ -1013,31 +1091,37 @@ export default function Home() {
)}
</button>
{typeList.map((type) => {
const normalized = normalizeMaterialType(type)
const isActive = Boolean(normalized) && selectedMaterialType === normalized
const isCustomTemplate = type.startsWith('template:')
const displayName = isCustomTemplate ? type.split(':').slice(2).join(':') : type
const normalized = isCustomTemplate ? null : normalizeMaterialType(type)
const isActive = isCustomTemplate
? type.split(':')[1] === selectedTemplateId
: Boolean(normalized) && selectedMaterialType === normalized
const count = materialTypesCount[type] ?? 0
const titleLabel = count > 0 ? `${displayName}:${count}个素材` : displayName
return (
<button
key={type}
onClick={() => handleMaterialTypeSelection(type)}
className={cn(chipClasses(isActive), 'relative group overflow-hidden')}
style={isActive ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={count > 0 ? `${type}:${count}个素材` : type}
title={titleLabel}
>
{/* 隐藏的测量元素:按「文字 (数量)」计算按钮宽度 */}
<span className="invisible inline-block whitespace-nowrap">
{count > 0 ? `${type} (${count})` : type}
{count > 0 ? `${displayName} (${count})` : displayName}
</span>
{/* 默认仅显示文字 */}
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="inline-block whitespace-nowrap transition-all duration-300 ease-in-out group-hover:opacity-0 group-hover:translate-x-[-10px]">
{type}
{displayName}
</span>
</span>
{/* 悬停时显示「文字+数量」 */}
{count > 0 && (
<span className="absolute inset-0 flex items-center justify-center opacity-0 translate-x-[10px] group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 ease-in-out whitespace-nowrap pointer-events-none">
{type} ({count})
{displayName} ({count})
</span>
)}
</button>
@ -1475,7 +1559,9 @@ export default function Home() {
}
}, [searchParams, status, materials, editingMaterial, router])
const isInitialLoading = status === 'loading' || (loading && materials.length === 0)
// 只在首次加载时显示加载中(NextAuth检查状态或首次加载素材)
// 如果已经有素材了,即使loading为true也不显示加载中(避免tag切换时闪烁)
const isInitialLoading = status === 'loading' || (loading && materials.length === 0 && !homeFilterReady)
if (isInitialLoading) {
return (
@ -1512,46 +1598,6 @@ export default function Home() {
}}
/>
{/* 内网切换提示 */}
{showIntranetTip && (
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<svg className="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="font-medium text-sm sm:text-base">
🚀 检测到您正在内网环境访问公网!
</p>
<p className="text-xs sm:text-sm opacity-90 mt-0.5">
切换到内网地址 ({(process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\/\//, '')}) 可获得 <strong>10倍以上</strong> 的速度提升,上传下载更快
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleSwitchToIntranet}
className="px-4 py-2 bg-white text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-medium text-sm whitespace-nowrap"
>
立即切换
</button>
<button
onClick={handleDismissIntranetTip}
className="px-3 py-2 hover:bg-blue-700 rounded-lg transition-colors"
title="关闭提示"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
)}
<RankingPanel />
<main className="container mx-auto px-4 py-8 max-w-7xl">
@ -1708,9 +1754,10 @@ export default function Home() {
<div className="mt-6">
<div
className={cn(
'grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 transition-all duration-300 ease-out',
isGridTransitioning && 'opacity-0 translate-y-2',
!isGridTransitioning && 'opacity-100 translate-y-0'
'grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
// 使用更平滑的过渡,避免闪烁
isGridTransitioning && 'opacity-70 pointer-events-none',
!isGridTransitioning && 'opacity-100 pointer-events-auto transition-opacity duration-150 ease-in-out'
)}
>
{materials.slice(0, displayedCount).map((material) => {
@ -1896,13 +1943,13 @@ export default function Home() {
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-1 mb-2 overflow-hidden whitespace-nowrap">
{material.materialType && (
{getMaterialTemplateLabel(material) && (
<Tag
size="xs"
color="blue"
className="shrink-0 font-semibold bg-blue-500/90 dark:bg-blue-600/90 text-white border-0 shadow-sm"
>
{material.materialType}
{getMaterialTemplateLabel(material)}
</Tag>
)}
{filterTags.length > 0 && filterTags.map((tag, index) => {

View File

@ -39,6 +39,10 @@ interface HeaderProps {
const THEME_STORAGE_KEY = 'admin-theme-preference'
// 默认导航栏 LOGO:优先使用构建时环境变量,否则使用内置静态资源
const DEFAULT_HEADER_LOGO =
process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png'
export default function Header({ session, compactMode }: HeaderProps) {
const styles = useThemeStyles()
const isAdmin = session ? ((session.user as any)?.role === 'ADMIN' || (session.user as any)?.isSuperAdmin) : false
@ -46,14 +50,12 @@ export default function Header({ session, compactMode }: HeaderProps) {
const [showDropdown, setShowDropdown] = useState(false)
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
const [networkChecked, setNetworkChecked] = useState(false)
const [showNetworkBanner, setShowNetworkBanner] = useState(false)
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const [showCategoryMenu, setShowCategoryMenu] = useState(false)
// 系统设置
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
const [headerLogoUrl, setHeaderLogoUrl] = useState(DEFAULT_HEADER_LOGO)
const [siteName, setSiteName] = useState('FLNXI素材库')
// Logo 加载状态
@ -136,6 +138,13 @@ export default function Header({ session, compactMode }: HeaderProps) {
useEffect(() => {
if (!headerLogoUrl) return
// 对于以 / 开头的相对路径(如 /logo/logo2.png),直接认为是站内静态资源,不做 URL 解析
if (headerLogoUrl.startsWith('/')) {
setLogoError(false)
return
}
try {
const parsed = new URL(headerLogoUrl)
const host = parsed.hostname.toLowerCase()
@ -163,7 +172,7 @@ export default function Header({ session, compactMode }: HeaderProps) {
if (response.ok) {
const settings = await response.json()
setHeaderBgColor(settings.headerBgColor || '#1f2937')
setHeaderLogoUrl(settings.headerLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '')
setHeaderLogoUrl(settings.headerLogoUrl || DEFAULT_HEADER_LOGO)
setSiteName(settings.siteName || 'FLNXI素材库')
}
} catch (error) {
@ -185,25 +194,13 @@ export default function Header({ session, compactMode }: HeaderProps) {
setIsHeaderDarkBg(isColorDark(detectionColor))
}, [headerBgColor, theme, isColorDark])
// 检测网络状态
// 检测网络状态(仅用于右上角「内网 / 公网」状态展示,不再弹出横幅)
useEffect(() => {
const detectNetwork = async () => {
try {
const networkStatus = await initializeNetwork()
setIsInternalNetwork(networkStatus.isInternal)
setNetworkChecked(true)
// 检查当前是否从外网访问
const currentHost = window.location.hostname
const intranet = (process.env.NEXT_PUBLIC_INTRANET_URL || '').replace(/^https?:\/\//, '').split('/')[0]
const isExternal = currentHost !== intranet && currentHost !== 'localhost'
setIsAccessingFromExternal(isExternal)
// 如果内网可达但当前从外网访问,显示切换提示(除非用户已关闭)
const bannerDismissed = sessionStorage.getItem('networkBannerDismissed')
if (networkStatus.isInternal && isExternal && !bannerDismissed) {
setShowNetworkBanner(true)
}
} catch (error) {
console.error('网络检测失败:', error)
setIsInternalNetwork(false)
@ -213,21 +210,6 @@ export default function Header({ session, compactMode }: HeaderProps) {
detectNetwork()
}, [])
// 切换到内网地址
const switchToInternal = () => {
const currentPath = window.location.pathname + window.location.search
const base = process.env.NEXT_PUBLIC_INTRANET_URL
if (base) {
window.location.href = `${base}${currentPath}`
}
}
// 关闭横幅
const dismissBanner = () => {
setShowNetworkBanner(false)
sessionStorage.setItem('networkBannerDismissed', 'true')
}
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -250,47 +232,10 @@ export default function Header({ session, compactMode }: HeaderProps) {
return (
<>
{/* 网络切换提示横幅 */}
{showNetworkBanner && (
<div className="fixed top-0 left-0 right-0 z-[60] bg-gradient-to-r from-green-500 to-emerald-500 text-white shadow-lg">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<p className="font-semibold">检测到您在内网环境</p>
<p className="text-sm text-white/90">切换到内网地址可获得更快的上传下载速度,并避免登录问题</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={switchToInternal}
className="px-4 py-2 bg-white text-green-600 rounded-lg font-medium hover:bg-green-50 transition-all shadow-sm"
aria-pressed={theme === 'dark'}
>
立即切换
</button>
<button
onClick={dismissBanner}
className="px-3 py-2 text-white/80 hover:text-white transition-all"
aria-label="关闭"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
)}
<header
className={cn(
'sticky z-50 shadow-sm',
showNetworkBanner ? 'top-[72px]' : 'top-0',
'top-0',
'backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
'transition-all duration-300'
)}

View File

@ -869,6 +869,7 @@ const isVideoFile = (file: File) => {
formData.append('imageResolution', templateFieldValues.imageResolution || '')
formData.append('colorMode', templateFieldValues.colorMode || '')
formData.append('materialKind', submittingMaterialKind)
formData.append('templateId', selectedTemplateId)
const xhr = new XMLHttpRequest()
xhrRef.current = xhr // 保存引用以便取消

114
copy-install-files.ps1 Normal file
View File

@ -0,0 +1,114 @@
# Copy install files to S:\flnxi-install
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$TargetDir = "S:\flnxi-install"
if (-not (Test-Path $TargetDir)) {
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
Write-Host "[OK] Created directory: $TargetDir" -ForegroundColor Green
} else {
Write-Host "[OK] Directory exists: $TargetDir" -ForegroundColor Green
}
Write-Host ""
Write-Host "[1/3] Copying files..." -ForegroundColor Blue
Write-Host ""
$files = @(
@{Source = "scripts\tools\install.sh"; Target = "install.sh"},
@{Source = "scripts\tools\uninstall.sh"; Target = "uninstall.sh"},
@{Source = "docker-compose.prod.yml"; Target = "docker-compose.prod.yml"},
@{Source = "fix-line-endings.sh"; Target = "fix-line-endings.sh"},
@{Source = "fix-encoding.sh"; Target = "fix-encoding.sh"}
)
$SuccessCount = 0
$FailCount = 0
foreach ($file in $files) {
$SourcePath = Join-Path $ScriptDir $file.Source
$TargetPath = Join-Path $TargetDir $file.Target
Write-Host " Copying: $($file.Target)"
Write-Host " From: $SourcePath"
Write-Host " To: $TargetPath"
if (Test-Path $SourcePath) {
try {
# 如果是 .sh 文件,需要转换为 Unix 格式(LF 行结束符)并保持 UTF-8 编码
if ($file.Source -like "*.sh") {
# 读取文件内容,保持 UTF-8 编码
$content = [System.IO.File]::ReadAllText($SourcePath, [System.Text.Encoding]::UTF8)
# 移除所有 CR(\r),只保留 LF(\n)
$content = $content -replace "`r`n", "`n" -replace "`r", "`n"
# 使用 UTF-8 无 BOM 编码保存
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($TargetPath, $content, $utf8NoBom)
Write-Host " [OK] Success (converted to Unix format, UTF-8)" -ForegroundColor Green
} else {
# 其他文件直接复制
Copy-Item -Path $SourcePath -Destination $TargetPath -Force
Write-Host " [OK] Success" -ForegroundColor Green
}
$SuccessCount++
} catch {
Write-Host " [FAIL] Error: $($_.Exception.Message)" -ForegroundColor Red
$FailCount++
}
} else {
Write-Host " [FAIL] Source file not found" -ForegroundColor Red
$FailCount++
}
Write-Host ""
}
Write-Host "[2/3] Copy completed" -ForegroundColor Blue
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Results" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success: $SuccessCount files"
Write-Host " Failed: $FailCount files"
Write-Host " Target: $TargetDir"
Write-Host ""
if ($FailCount -eq 0) {
Write-Host "[OK] All files copied successfully" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Transfer files from $TargetDir to FeiNiu NAS"
Write-Host " 2. SSH to NAS"
Write-Host " 3. Run: bash install.sh"
Write-Host " 4. To uninstall: bash uninstall.sh"
Write-Host ""
} else {
Write-Host "[FAIL] Some files failed to copy" -ForegroundColor Red
Write-Host ""
}
Write-Host "[3/3] Verifying files..." -ForegroundColor Blue
Write-Host ""
$AllFilesExist = $true
foreach ($file in $files) {
$TargetPath = Join-Path $TargetDir $file.Target
if (Test-Path $TargetPath) {
$FileSize = (Get-Item $TargetPath).Length
$FileSizeKB = [math]::Round($FileSize / 1KB, 2)
Write-Host " [OK] $($file.Target) - $FileSizeKB KB" -ForegroundColor Green
} else {
Write-Host " [FAIL] $($file.Target) not found" -ForegroundColor Red
$AllFilesExist = $false
}
}
Write-Host ""
if ($AllFilesExist) {
Write-Host "[OK] All files verified" -ForegroundColor Green
} else {
Write-Host "[FAIL] Some files verification failed" -ForegroundColor Red
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan

View File

@ -850,8 +850,11 @@ function Main {
Write-Info "开始Docker构建: $fullImage"
# 构建参数
# 为了避免沿用旧的 .next 构建产物,这里强制禁用 Docker Build 缓存
# 确保每次部署到服务器的镜像都包含最新的 API 逻辑和调试日志
$buildArgs = @(
"build",
"--no-cache",
"-t", $fullImage,
"-f", "Dockerfile",
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",

View File

@ -1,5 +1,3 @@
version: '3.8'
# ============================================
# FLNXI素材库 - Pro版配置
# 支持内网穿透,资源存储在MinIO存储桶
@ -83,9 +81,18 @@ services:
restart: unless-stopped
ports:
- "${APP_PORT:-4320}:3000"
# 使用 env_file 自动读取 .env 文件中的所有环境变量(包括 DATABASE_URL)
# 这样 Docker Compose 会自动处理所有变量,无需手动拼接
env_file:
- .env
environment:
# 注意:
# - SERVER_IP 必须在安装脚本或 .env 中配置为「本机在局域网中的访问 IP」,例如:192.168.41.144
# - 对于一键安装用户,install.sh 会自动生成 .env.production 并写入 SERVER_IP,这里直接引用即可
- NODE_ENV=production
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-flnxi}
# DATABASE_URL 从 .env 文件自动读取(install.sh 会生成正确编码的 URL)
# 如果 .env 文件中没有 DATABASE_URL,则使用默认值(向后兼容)
- DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-flnxi}}
- DATABASE_DRIVER=node
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://minio:9000
@ -93,14 +100,21 @@ services:
- S3_ACCESS_KEY_ID=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123456}
- S3_REGION=us-east-1
- S3_PUBLIC_DOMAIN=http://${SERVER_IP:-127.0.0.1}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials}
# 对外访问 MinIO 的地址(供前端和分享链接使用),必须是局域网 / 公网可访问的 IP 或域名
# 这里不再默认 127.0.0.1,避免在 NAS 等环境下写死 localhost
- S3_PUBLIC_DOMAIN=http://${SERVER_IP}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials}
- S3_ENABLE_PATH_STYLE=1
- S3_SET_ACL=1
- S3_PREVIEW_URL_EXPIRE_IN=7200
- S3_FILE_PATH=materials/
- NEXT_PUBLIC_S3_FILE_PATH=materials/
- NEXTAUTH_URL=http://${SERVER_IP:-localhost}:${APP_PORT:-4320}
# NextAuth 的外部访问地址,从 .env(.production) 中注入(不在这里拼接,以免 SERVER_IP 为空时产生 http://:4320)
# install.sh / 部署脚本会写入 NEXTAUTH_URL,例如:http://192.168.41.144:4320
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-change-this-to-a-random-string-in-production}
# 初始管理员账号(供 /api/system/init 使用)
- INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@flnxi.com}
- INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-88888888}
volumes:
- ./uploads:/app/uploads
depends_on:

24
fix-encoding.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# 修复终端编码设置,解决中文乱码问题
# 使用方法:bash fix-encoding.sh 或 source fix-encoding.sh
echo "设置终端编码为 UTF-8..."
# 设置环境变量
export LANG=zh_CN.UTF-8
export LC_ALL=zh_CN.UTF-8
# 如果系统支持,设置终端编码
if command -v locale &> /dev/null; then
export LANG=zh_CN.UTF-8
export LC_ALL=zh_CN.UTF-8
fi
echo "[OK] 编码已设置为 UTF-8"
echo ""
echo "现在可以运行脚本了:"
echo " bash install.sh"
echo " 或"
echo " bash uninstall.sh"
echo ""

31
fix-line-endings.sh Normal file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# 修复脚本文件的行结束符问题
# 使用方法:bash fix-line-endings.sh
echo "修复脚本文件的行结束符..."
echo ""
# 修复 install.sh
if [ -f "install.sh" ]; then
sed -i 's/\r$//' install.sh
chmod +x install.sh
echo "[OK] install.sh 已修复"
else
echo "[FAIL] install.sh 不存在"
fi
# 修复 uninstall.sh
if [ -f "uninstall.sh" ]; then
sed -i 's/\r$//' uninstall.sh
chmod +x uninstall.sh
echo "[OK] uninstall.sh 已修复"
else
echo "[FAIL] uninstall.sh 不存在"
fi
echo ""
echo "修复完成!现在可以运行:"
echo " bash install.sh"
echo " 或"
echo " bash uninstall.sh"

View File

@ -32,13 +32,17 @@ export function resolveBaseUrl(req?: NextRequest): string {
}
export const defaultLogos = {
header: process.env.DEFAULT_HEADER_LOGO_URL || '',
login: process.env.DEFAULT_LOGIN_LOGO_URL || '',
// 后台默认使用内置静态资源;如需自定义,可通过环境变量覆盖
// logo1:浅色 Logo,适合深色背景(导航栏)
// logo2:深色 Logo,适合浅色背景(登录页)
header: process.env.DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png',
login: process.env.DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png',
}
export const publicDefaults = {
header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '',
login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '',
// 前端默认 Logo,与后端 defaultLogos 保持一致,方便通过 env 统一覆盖
header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png',
login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png',
intranetUrl: process.env.NEXT_PUBLIC_INTRANET_URL || '',
}

View File

@ -17,6 +17,36 @@ const s3Client = new S3Client({
maxAttempts: 10, // 失败重试10次,确保上传成功
})
const normalizePublicDomain = () => {
const raw = (process.env.S3_PUBLIC_DOMAIN || '').trim()
if (!raw) return ''
try {
const url = new URL(raw)
const host = url.hostname
if (host === '127.0.0.1' || host === 'localhost') {
// 尝试使用 SERVER_IP 或 APP_PUBLIC_URL 的主机名替换,避免浏览器访问本机导致 404
const fallbackHost =
process.env.SERVER_IP ||
(() => {
const appUrl = process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || ''
if (!appUrl) return ''
try {
return new URL(appUrl).hostname
} catch {
return ''
}
})()
if (fallbackHost) {
url.hostname = fallbackHost
}
}
return url.toString().replace(/\/$/, '')
} catch {
return raw.replace(/\/$/, '')
}
}
const buildKeyPrefix = () => {
const sanitize = (value?: string | null) => {
if (!value) return null
@ -48,7 +78,7 @@ export function extractS3Key(fileUrlOrKey: string): string {
return sanitizeKey(fileUrlOrKey)
}
const normalizedDomain = (process.env.S3_PUBLIC_DOMAIN || '').replace(/\/$/, '')
const normalizedDomain = normalizePublicDomain()
if (normalizedDomain && fileUrlOrKey.startsWith(normalizedDomain)) {
return sanitizeKey(fileUrlOrKey.slice(normalizedDomain.length))
}
@ -79,7 +109,7 @@ export async function uploadToS3(
try {
await s3Client.send(command)
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').replace(/\/$/, '')
const publicDomain = normalizePublicDomain()
return publicDomain ? `${publicDomain}/${key}` : key
} catch (error) {
// S3上传失败会自动重试(maxAttempts: 10),这里只记录日志

159
lib/template-validator.ts Normal file
View File

@ -0,0 +1,159 @@
/**
* 模板验证工具
*
* 提供统一的模板验证逻辑,支持内置模板和自定义模板
*/
import { prisma } from '@/lib/prisma'
// 内置模板key列表
export const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
'ai-material',
'video-material',
'model-material',
'other-material',
] as const
export type BuiltinTemplateKey = typeof BUILTIN_TEMPLATE_KEYS[number]
/**
* 模板验证结果
*/
export type TemplateValidationResult = {
valid: boolean
isBuiltin: boolean
templateKey?: string
error?: string
}
/**
* 验证模板ID是否有效
*
* @param templateId - 模板ID(可以是内置模板或自定义模板)
* @returns 验证结果
*/
export async function validateTemplateId(
templateId: string | null | undefined
): Promise<TemplateValidationResult> {
// 如果没有提供 templateId,视为有效(向后兼容)
if (!templateId) {
return { valid: true, isBuiltin: false }
}
// 检查是否为内置模板(builtin-* 格式)
const isBuiltinTemplate = templateId.startsWith('builtin-')
if (isBuiltinTemplate) {
// 内置模板:验证模板key是否在预设列表中
const templateKey = templateId.replace('builtin-', '') as BuiltinTemplateKey
if (!BUILTIN_TEMPLATE_KEYS.includes(templateKey)) {
return {
valid: false,
isBuiltin: true,
templateKey,
error: `无效的内置模板:${templateId}。有效的内置模板包括:${BUILTIN_TEMPLATE_KEYS.map(k => `builtin-${k}`).join(', ')}`,
}
}
return {
valid: true,
isBuiltin: true,
templateKey,
}
}
// 自定义模板:从数据库验证
try {
const template = await prisma.uploadTemplate.findUnique({
where: { id: templateId },
select: {
id: true,
name: true,
status: true,
},
})
if (!template) {
return {
valid: false,
isBuiltin: false,
error: `自定义模板不存在:${templateId}。请检查模板是否已被删除或ID是否正确。`,
}
}
// 检查模板状态
if (template.status !== 'active') {
return {
valid: false,
isBuiltin: false,
error: `模板"${template.name}"当前状态为"${template.status}",无法使用。请联系管理员启用该模板。`,
}
}
return {
valid: true,
isBuiltin: false,
}
} catch (error) {
console.error('[TemplateValidator] Error checking custom template:', error)
return {
valid: false,
isBuiltin: false,
error: `验证模板时发生错误:${error instanceof Error ? error.message : '未知错误'}`,
}
}
}
/**
* 检查模板ID是否为内置模板
*
* @param templateId - 模板ID
* @returns 是否为内置模板
*/
export function isBuiltinTemplate(templateId: string | null | undefined): boolean {
if (!templateId) return false
return templateId.startsWith('builtin-')
}
/**
* 从内置模板ID提取模板key
*
* @param templateId - 内置模板ID(如 builtin-video-material)
* @returns 模板key(如 video-material)
*/
export function extractBuiltinTemplateKey(templateId: string): BuiltinTemplateKey | null {
if (!isBuiltinTemplate(templateId)) return null
const key = templateId.replace('builtin-', '')
return BUILTIN_TEMPLATE_KEYS.includes(key as BuiltinTemplateKey)
? (key as BuiltinTemplateKey)
: null
}
/**
* 获取模板的完整信息(包括字段)
*
* @param templateId - 模板ID
* @returns 模板信息,如果不存在返回 null
*/
export async function getTemplateWithFields(templateId: string) {
if (isBuiltinTemplate(templateId)) {
// 内置模板需要动态生成
const { createBuiltinTemplate } = await import('@/lib/upload-template-presets')
const templateKey = extractBuiltinTemplateKey(templateId)
if (!templateKey) return null
return createBuiltinTemplate(templateKey)
}
// 自定义模板从数据库读取
return prisma.uploadTemplate.findUnique({
where: { id: templateId },
include: {
fields: {
orderBy: { orderIndex: 'asc' },
},
},
})
}

143
lib/url-rewrite.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* 存储 URL 重写工具
*
* 目标:即使数据库里存的是 http://localhost:9000/... 这样的地址,
* 也要在「返回给前端之前」自动替换为实际可访问的 MinIO 地址。
*/
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
function getSafeMinioBase(): { protocol: string; host: string; port: string } | null {
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim()
const serverIp = (process.env.SERVER_IP || '').trim()
const minioPort = (process.env.MINIO_API_PORT || '9000').trim()
const appUrl = (process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '').trim()
let host: string | null = null
let port: string = minioPort || '9000'
// 1) 端口优先取 S3_PUBLIC_DOMAIN 上的端口(如果有)
if (publicDomain) {
try {
const url = new URL(publicDomain)
if (url.port) {
port = url.port
}
} catch {
// ignore
}
}
// 2) 主机优先使用 SERVER_IP
if (serverIp && !LOCAL_HOSTS.has(serverIp)) {
host = serverIp
}
// 3) 如果没有 SERVER_IP,再从 APP_PUBLIC_URL / NEXTAUTH_URL 推断
if (!host && appUrl) {
try {
const url = new URL(appUrl)
if (!LOCAL_HOSTS.has(url.hostname)) {
host = url.hostname
}
} catch {
// ignore
}
}
// 4) 如果 publicDomain 本身就是非 localhost 的,也可以作为兜底 host
if (!host && publicDomain) {
try {
const url = new URL(publicDomain)
if (!LOCAL_HOSTS.has(url.hostname)) {
host = url.hostname
}
} catch {
// ignore
}
}
if (!host) {
return null
}
return {
protocol: 'http:',
host,
port: port || '9000',
}
}
/**
* 将存储相关的 URL 中的 localhost/127.0.0.1 替换为 SERVER_IP:MINIO_API_PORT
* - 仅在 URL 为 http/https 且 host 为本机时生效
* - 其他情况(如相对路径、第三方 CDN)保持不变
*/
export function rewriteStorageUrlForClient(url?: string | null): string | null {
if (!url || typeof url !== 'string') return url ?? null
const trimmed = url.trim()
if (!trimmed) return null
// 非 http(s) 直接返回(可能是相对路径或 data: URL)
if (!/^https?:\/\//i.test(trimmed)) {
return trimmed
}
const minioBase = getSafeMinioBase()
if (!minioBase) {
// 没有 SERVER_IP 配置时,不做任何修改,避免返回错误地址
return trimmed
}
try {
const parsed = new URL(trimmed)
if (!LOCAL_HOSTS.has(parsed.hostname)) {
// 不是 localhost / 127.0.0.1,直接返回
return trimmed
}
// 将 localhost:9000 替换为 SERVER_IP:MINIO_API_PORT
parsed.protocol = minioBase.protocol
parsed.hostname = minioBase.host
parsed.port = minioBase.port
return parsed.toString()
} catch {
// URL 解析失败时保持原样,避免引入新的错误
return trimmed
}
}
/**
* 批量重写素材对象中的所有存储 URL 字段
*/
export function rewriteMaterialStorageUrlsForClient<T extends Record<string, any>>(material: T): T {
if (!material || typeof material !== 'object') return material
const cloned: any = { ...material }
const urlFields = [
'previewUrl',
'videoCoverUrl',
'videoPreview540Url',
'videoPreviewClipUrl',
] as const
urlFields.forEach((field) => {
if (field in cloned) {
cloned[field] = rewriteStorageUrlForClient(cloned[field])
}
})
// displayImages: string[]
if (Array.isArray(cloned.displayImages)) {
cloned.displayImages = cloned.displayImages.map((item: any) =>
typeof item === 'string' ? rewriteStorageUrlForClient(item) : item
)
}
return cloned as T
}

BIN
logo/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
logo/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -324,8 +324,11 @@ if (-not $SkipBuild) {
try {
# 构建参数(参考 deploy-new-fixed.ps1)
# 为了避免使用旧的构建缓存,这里强制禁用 Docker Build 缓存
# 确保每次推送前都会重新编译 .next 产物,包含最新的 API 逻辑和日志
$buildArgs = @(
"build",
"--no-cache",
"-t", $localImage,
"-f", "Dockerfile",
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",

View File

@ -0,0 +1,118 @@
#!/bin/bash
# ============================================
# FLNXI素材库 - 修复 DATABASE_URL 脚本
# 用于修复数据库连接字符串问题
# ============================================
set -e
echo "========================================"
echo " 修复 DATABASE_URL 配置"
echo "========================================"
echo ""
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 检查 .env.production 文件是否存在
if [ ! -f .env.production ]; then
echo -e "${RED}错误: 未找到 .env.production 文件${NC}"
echo "请先运行 install.sh 进行安装"
exit 1
fi
# 加载环境变量
source .env.production
# 检查是否已有 DATABASE_URL
if [ -n "$DATABASE_URL" ]; then
echo -e "${GREEN}✓ 检测到 DATABASE_URL 已存在${NC}"
echo "当前 DATABASE_URL: ${DATABASE_URL:0:50}..."
# 检查是否需要更新(如果密码未编码)
if echo "$DATABASE_URL" | grep -q "=="; then
echo -e "${YELLOW}警告: DATABASE_URL 中的密码可能未正确编码${NC}"
echo "正在重新生成..."
NEED_UPDATE=1
else
echo -e "${GREEN}✓ DATABASE_URL 格式正确${NC}"
NEED_UPDATE=0
fi
else
echo -e "${YELLOW}未找到 DATABASE_URL,正在生成...${NC}"
NEED_UPDATE=1
fi
# 如果需要更新,生成新的 DATABASE_URL
if [ "$NEED_UPDATE" = "1" ]; then
if [ -z "$POSTGRES_PASSWORD" ]; then
echo -e "${RED}错误: 未找到 POSTGRES_PASSWORD${NC}"
exit 1
fi
echo "正在对密码进行 URL 编码..."
# URL 编码密码
if command -v python3 &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v python &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v perl &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(perl -MURI::Escape -e "print uri_escape('$POSTGRES_PASSWORD');")
else
# 如果没有 Python 或 Perl,使用 sed 简单替换常见特殊字符
POSTGRES_PASSWORD_ENCODED=$(echo "$POSTGRES_PASSWORD" | sed 's/+/%2B/g; s/\//%2F/g; s/=/%3D/g; s/@/%40/g; s/:/%3A/g')
fi
POSTGRES_USER=${POSTGRES_USER:-postgres}
POSTGRES_DB=${POSTGRES_DB:-flnxi}
NEW_DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD_ENCODED}@postgres:5432/${POSTGRES_DB}"
echo -e "${GREEN}✓ 已生成新的 DATABASE_URL${NC}"
echo "新 DATABASE_URL: postgresql://${POSTGRES_USER}:***@postgres:5432/${POSTGRES_DB}"
# 更新 .env.production 文件
if grep -q "^DATABASE_URL=" .env.production; then
# 如果已存在,替换
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s|^DATABASE_URL=.*|DATABASE_URL=$NEW_DATABASE_URL|" .env.production
else
# Linux
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=$NEW_DATABASE_URL|" .env.production
fi
echo -e "${GREEN}✓ 已更新 .env.production 中的 DATABASE_URL${NC}"
else
# 如果不存在,追加
echo "" >> .env.production
echo "# 完整的数据库连接URL(密码已URL编码,自动生成)" >> .env.production
echo "DATABASE_URL=$NEW_DATABASE_URL" >> .env.production
echo -e "${GREEN}✓ 已添加 DATABASE_URL 到 .env.production${NC}"
fi
# 更新 .env 文件
cp .env.production .env
echo -e "${GREEN}✓ 已同步到 .env 文件${NC}"
fi
echo ""
echo "========================================"
echo -e "${GREEN} 修复完成!${NC}"
echo "========================================"
echo ""
echo "下一步操作:"
echo " 1. 重启应用容器以应用新配置:"
echo " sudo docker compose -f docker-compose.prod.yml restart app"
echo ""
echo " 2. 等待容器启动后,执行数据库初始化:"
echo " sudo docker compose -f docker-compose.prod.yml exec app npx prisma db push"
echo ""
echo " 3. 如果仍有问题,检查容器内的环境变量:"
echo " sudo docker compose -f docker-compose.prod.yml exec app env | grep DATABASE_URL"
echo ""

View File

@ -145,15 +145,13 @@ if [ ! -f .env.production ]; then
# 询问初始管理员账号
echo ""
echo "配置初始管理员账号 (首次登录使用):"
read -p "管理员邮箱 [admin@flnxi.com]: " INIT_ADMIN_EMAIL
INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@flnxi.com}
read -sp "管理员密码 (输入时不会显示): " INIT_ADMIN_PASSWORD
echo
if [ -z "$INIT_ADMIN_PASSWORD" ]; then
INIT_ADMIN_PASSWORD=$(openssl rand -base64 16 2>/dev/null || cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
echo -e "${YELLOW}已自动生成密码,请查看下方保存的信息${NC}"
fi
# 固定使用 admin@flnxi.com 和 88888888,不可更改
INIT_ADMIN_EMAIL="admin@flnxi.com"
INIT_ADMIN_PASSWORD="88888888"
echo -e "${GREEN}✓ 初始管理员账号已固定配置${NC}"
echo " - 邮箱: $INIT_ADMIN_EMAIL"
echo " - 密码: $INIT_ADMIN_PASSWORD"
echo -e "${YELLOW}提示: 首次登录后系统将强制要求修改密码${NC}"
# 选择部署环境
echo ""
@ -190,6 +188,22 @@ if [ ! -f .env.production ]; then
HARBOR_PASSWORD=""
fi
# URL 编码密码(处理特殊字符)
# 使用 Python 或 Perl 进行 URL 编码,如果没有则使用 sed 简单替换
if command -v python3 &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v python &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v perl &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(perl -MURI::Escape -e "print uri_escape('$POSTGRES_PASSWORD');")
else
# 如果没有 Python 或 Perl,使用 sed 简单替换常见特殊字符
POSTGRES_PASSWORD_ENCODED=$(echo "$POSTGRES_PASSWORD" | sed 's/+/%2B/g; s/\//%2F/g; s/=/%3D/g; s/@/%40/g; s/:/%3A/g')
fi
# 生成完整的 DATABASE_URL(密码已编码)
DATABASE_URL="postgresql://postgres:${POSTGRES_PASSWORD_ENCODED}@postgres:5432/flnxi"
# 创建 .env.production 文件(客户安装使用)
cat > .env.production << EOF
# FLNXI素材库 - 生产环境配置
@ -203,6 +217,8 @@ APP_PORT=$APP_PORT
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_DB=flnxi
# 完整的数据库连接URL(密码已URL编码,可直接使用)
DATABASE_URL=$DATABASE_URL
# MinIO 配置
MINIO_ROOT_USER=minioadmin
@ -237,6 +253,9 @@ HARBOR_PASSWORD=$HARBOR_PASSWORD
EOF
fi
# 同步一份 .env(供 docker compose 直接读取,无需客户手动改名)
cp .env.production .env
echo -e "${GREEN}✓ 环境配置文件已创建${NC}"
echo ""
echo -e "${YELLOW}重要信息 (请保存):${NC}"
@ -267,6 +286,71 @@ else
elif [ -f .env ]; then
source .env
fi
# 检查并修复 DATABASE_URL(确保密码正确编码)
if [ -n "$POSTGRES_PASSWORD" ]; then
NEED_FIX_DATABASE_URL=0
# 检查 DATABASE_URL 是否存在
if [ -z "$DATABASE_URL" ]; then
echo -e "${YELLOW}检测到缺少 DATABASE_URL,正在生成...${NC}"
NEED_FIX_DATABASE_URL=1
# 检查 DATABASE_URL 中的密码是否未编码(包含特殊字符如 =、+、/)
elif echo "$DATABASE_URL" | grep -qE '[^%](=|/|\+)'; then
echo -e "${YELLOW}检测到 DATABASE_URL 中的密码可能未正确编码,正在修复...${NC}"
NEED_FIX_DATABASE_URL=1
fi
if [ "$NEED_FIX_DATABASE_URL" = "1" ]; then
# URL 编码密码
if command -v python3 &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v python &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(python -c "import urllib.parse; print(urllib.parse.quote('$POSTGRES_PASSWORD', safe=''))")
elif command -v perl &> /dev/null; then
POSTGRES_PASSWORD_ENCODED=$(perl -MURI::Escape -e "print uri_escape('$POSTGRES_PASSWORD');")
else
# 如果没有 Python 或 Perl,使用 sed 简单替换常见特殊字符
POSTGRES_PASSWORD_ENCODED=$(echo "$POSTGRES_PASSWORD" | sed 's/+/%2B/g; s/\//%2F/g; s/=/%3D/g; s/@/%40/g; s/:/%3A/g')
fi
POSTGRES_USER=${POSTGRES_USER:-postgres}
POSTGRES_DB=${POSTGRES_DB:-flnxi}
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD_ENCODED}@postgres:5432/${POSTGRES_DB}"
# 更新 .env.production 文件
if [ -f .env.production ]; then
if grep -q "^DATABASE_URL=" .env.production; then
# 如果已存在,替换
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s|^DATABASE_URL=.*|DATABASE_URL=$DATABASE_URL|" .env.production
else
# Linux
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=$DATABASE_URL|" .env.production
fi
echo -e "${GREEN}✓ 已更新 .env.production 中的 DATABASE_URL${NC}"
else
# 如果不存在,追加
echo "" >> .env.production
echo "# 完整的数据库连接URL(密码已URL编码,自动生成)" >> .env.production
echo "DATABASE_URL=$DATABASE_URL" >> .env.production
echo -e "${GREEN}✓ 已添加 DATABASE_URL 到 .env.production${NC}"
fi
fi
else
echo -e "${GREEN}✓ DATABASE_URL 配置正确${NC}"
fi
fi
# 确保 .env 文件与 .env.production 同步(供 docker compose 使用)
# 如果 .env.production 更新了,也要更新 .env
if [ -f .env.production ]; then
if [ ! -f .env ] || [ .env.production -nt .env ]; then
cp .env.production .env
echo -e "${GREEN}✓ 已同步 .env.production 到 .env(供 docker compose 使用)${NC}"
fi
fi
fi
# 创建必要的目录
@ -326,9 +410,11 @@ echo ""
echo "初始化数据库..."
max_retries=30
retry_count=0
db_init_succeeded=0
while [ $retry_count -lt $max_retries ]; do
if $DOCKER_COMPOSE -f docker-compose.prod.yml exec -T app npx prisma db push --accept-data-loss 2>/dev/null; then
echo -e "${GREEN}✓ 数据库初始化成功${NC}"
db_init_succeeded=1
break
else
retry_count=$((retry_count + 1))
@ -342,6 +428,41 @@ while [ $retry_count -lt $max_retries ]; do
fi
done
# 调用系统初始化接口,自动创建初始管理员账号等基础数据
if [ "$db_init_succeeded" -eq 1 ]; then
echo ""
echo "初始化系统管理员账号..."
# 如果缺少 APP_PORT / SERVER_IP,则使用保守默认值
INIT_SERVER_IP=${SERVER_IP:-127.0.0.1}
INIT_APP_PORT=${APP_PORT:-4320}
INIT_URL="http://${INIT_SERVER_IP}:${INIT_APP_PORT}/api/system/init"
if command -v curl >/dev/null 2>&1; then
max_init_retries=30
init_retry=0
while [ $init_retry -lt $max_init_retries ]; do
if curl -fsS -X POST "$INIT_URL" >/dev/null 2>&1; then
echo -e "${GREEN}✓ 系统初始化成功(管理员账号已创建或已存在)${NC}"
break
else
init_retry=$((init_retry + 1))
if [ $init_retry -lt $max_init_retries ]; then
echo "等待应用接口就绪... ($init_retry/$max_init_retries)"
sleep 5
else
echo -e "${RED}系统初始化接口调用失败,请稍后手动执行:${NC}"
echo " curl -X POST \"$INIT_URL\""
fi
fi
done
else
echo -e "${YELLOW}警告: 系统中未安装 curl,无法自动调用初始化接口${NC}"
echo "请在浏览器中访问以下地址完成初始化,或在服务器上手动执行 curl 命令:"
echo " $INIT_URL"
fi
fi
# 显示安装完成信息
echo ""
echo "========================================"
@ -361,7 +482,7 @@ echo " 1. 访问 http://$SERVER_IP:$APP_PORT"
echo " 2. 使用初始管理员账号登录:"
echo " - 邮箱: $INIT_ADMIN_EMAIL"
echo " - 密码: $INIT_ADMIN_PASSWORD"
echo " 3. 登录后可在系统设置中修改密码"
echo " 3. 登录后系统将强制要求修改密码(首次登录必须修改)"
echo ""
echo -e "${BLUE}常用命令:${NC}"
echo " 查看日志: $DOCKER_COMPOSE -f docker-compose.prod.yml logs -f"

View File

@ -260,16 +260,19 @@
43.分类管理中又冒出来许多的奇怪的分类小卡片,之前也是有这个问题的, ✅
- ✅ 后端解析分类选项时统一去除 `[]` 和 `\"` 的嵌套字符,并在更新时做归一化,异常项不再生成卡片
44.首页素材预览弹窗内,只有一张预览图的话,预览图的位置太靠下,
44.首页上传素材面板内选择上传模板如果是自定义模板的话,素材卡片的用来区分不同素材模板的标不会显示成自定义模板的名字,而是继续使用预制模板的名字
- ✅已解决
45.监控仪表板内本月新素材(每日上传)卡片的柱状图下面只显示日期,不用显示月份
45.首页tag按钮,切换的时候会闪动,越往后的按钮闪动越厉害,应该是和排布有关系
46.自定义模板的问题解决了,现在预制模板的问题又来了,我尝试使用3dmax的预制模板上传素材,但是提示我:模板不存在:builtin-3dmax-material
47,自定义模板的tag按钮里面不显示素材数量
现在的素材都是统一放到储存桶里了吗,

View File

@ -0,0 +1,6 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0copy-install-files.ps1"
pause