feat: 部门管理和文件上传优化

新功能:
1. 部门管理功能
   - 管理员可在系统设置中添加/删除部门
   - 用户注册时从下拉框选择部门
   - 新增Department数据库表和API

2. 文件上传优化
   - 支持直接上传源文件(.max/.psd/.ai/.cad/.dwg/.c4d)
   - 自动识别文件类型并设置素材类型
   - 压缩包需手动选择类型
   - 优化拖拽上传体验

数据库变更:
- 新增departments表
- User表新增department字段
This commit is contained in:
chenpei 2025-10-26 14:06:19 +08:00
parent 69f774aa36
commit d17f6df2c7
17 changed files with 35967 additions and 84 deletions

View File

@ -0,0 +1,198 @@
# 数据库迁移完成报告
## 📊 迁移概况
**迁移时间**: 2025-10-26
**迁移类型**: 云数据库 → 本地NAS数据库
**迁移状态**: ✅ 成功完成
---
## 🎯 迁移详情
### 1. 数据库连接信息
#### 迁移前(云数据库)
```
Host: 149.88.95.87:5432
Database: lobechat
User: postgres
Status: 已废弃(数据已清空)
```
#### 迁移后(本地NAS)
```
Host: 192.168.58.20:5432
Database: lobechat
User: gongxiangpan
Status: ✅ 正常运行
```
### 2. Docker Compose配置更新
已更新以下环境变量:
- `DATABASE_URL`: postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
- `PGVECTOR_DB_URL`: postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
配置文件位置: `/mnt/PoolOne/apps/youyidian-3d/docker-compose.yml`
---
## 📋 数据库表结构
### 当前数据统计
| 表名 | 记录数 | 说明 |
|-----|-------|------|
| **users** | 1 | 管理员用户(admin) |
| **categories** | 1 | 默认分类(默认店铺) |
| **scenes** | 1 | 默认场景(默认场景) |
| **materials** | 0 | 素材数据(待用户上传) |
| **material_likes** | 0 | 点赞记录 |
| **system_settings** | 1 | 系统设置(已配置) |
### 新增字段
#### materials表
- ✅ **materialType**: 素材类型字段(支持:3DMAX/PSD/AI/CAD/C4D/其他)
- 默认值: '3DMAX'
- 索引: materials_materialType_idx
#### system_settings表(新建)
- **id**: 主键
- **headerBgColor**: 顶部导航栏背景色(默认: #1f2937)
- **logoUrl**: LOGO URL(默认: http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png)
- **siteName**: 网站名称(默认: 由壹点3D素材)
- **createdAt**: 创建时间
- **updatedAt**: 更新时间
---
## 👤 管理员账户信息
### 默认管理员
```
用户名: admin
邮箱: admin
密码: admin123456
角色: ADMIN
状态: 激活
下载权限: 是
```
⚠️ **重要提示**: 请登录后立即修改管理员密码!
---
## 📁 文件存储信息
### 本地文件
- **位置**: `/mnt/PoolOne/apps/youyidian-3d/uploads/`
- **现有文件**: 325个素材压缩包(zip/rar/7z)
- **状态**: 文件完整,但未导入数据库
### S3存储(MinIO)
- **Bucket**: jsxiangche
- **Endpoint**: https://s3.1080p.hk
- **预览图**: 334个(webp格式)
- **状态**: 正常可访问
---
## 🔧 数据恢复方案
由于Prisma客户端兼容性问题,采用以下方案:
### 方案:用户重新上传
**优点**:
- 数据准确性高
- 可以重新审核和分类
- 用户可以补充完整的元数据(SPU、分类、场景等)
**步骤**:
1. ✅ 数据库schema已迁移完成
2. ✅ 管理员账户已创建
3. ✅ 默认分类和场景已创建
4. ✅ 系统设置已配置
5. 📝 等待用户通过网站上传素材
### 访问信息
- **内网地址**: http://192.168.58.20:4320
- **公网地址**: 通过frp穿透访问
- **管理后台**: http://192.168.58.20:4320/admin
---
## ✅ 验证清单
- [x] 数据库连接已切换到本地NAS
- [x] users表已创建并包含admin用户
- [x] categories表已创建并包含默认分类
- [x] scenes表已创建并包含默认场景
- [x] materials表已更新(添加materialType字段)
- [x] system_settings表已创建并配置
- [x] Docker容器已重启并正常运行
- [x] 所有表索引已创建
- [x] 外键约束已配置
---
## 📝 后续操作建议
### 1. 立即执行
- [ ] 登录管理后台,修改admin密码
- [ ] 测试上传功能是否正常
- [ ] 添加常用的分类和场景
### 2. 数据管理
- [ ] 通知用户重新上传素材
- [ ] 设置素材审核流程
- [ ] 配置存储空间监控
### 3. 系统优化
- [ ] 配置自动备份策略
- [ ] 设置定期数据清理任务
- [ ] 优化数据库性能参数
---
## 🛠 技术支持
### 数据库操作命令
**连接数据库**:
```bash
ssh -p 2322 root@180.97.221.140
docker run --rm -it postgres:15-alpine psql postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
```
**查看统计**:
```sql
SELECT COUNT(*) FROM materials;
SELECT COUNT(*) FROM users;
SELECT * FROM system_settings;
```
**重启应用**:
```bash
cd /mnt/PoolOne/apps/youyidian-3d
docker compose restart
```
---
## 📌 注意事项
1. **旧数据无法自动恢复**: 由于云数据库已清空,无法恢复原有的325个素材的元数据(分类、场景、SPU等)
2. **文件仍然存在**: 本地的325个压缩包文件和334个S3预览图都完整保存
3. **需要重新上传**: 用户需要通过网站上传功能重新导入素材,以便正确关联元数据
4. **密码安全**: 当前使用的是临时密码hash方式,建议登录后立即修改密码
---
## 🎉 迁移成功!
数据库已成功从云端迁移到本地NAS,所有表结构完整,系统可以正常使用。
现在可以开始让用户重新上传素材了!

223
DEPLOYMENT_SUCCESS.md Normal file
View File

@ -0,0 +1,223 @@
# 🎉 部署成功验证报告
**部署时间**: 2025-10-26
**状态**: ✅ 成功运行
---
## 📊 服务状态
### Docker 容器
```
容器名称: youyidian-3d-material
状态: Up (运行中)
端口映射: 0.0.0.0:4320 -> 3000
镜像: youyidian-3d-app (最新构建)
```
### 应用验证
- ✅ **健康检查**: http://localhost:4320/api/health - 正常
- ✅ **首页访问**: http://localhost:4320 - 正常(重定向到登录页)
- ✅ **数据库连接**: 192.168.58.20:5432/lobechat - 正常
---
## 🗄️ 数据库状态
### 连接信息
- **地址**: 192.168.58.20:5432
- **数据库**: lobechat
- **用户**: gongxiangpan
- **状态**: ✅ 正常连接
### 数据表统计
| 表名 | 记录数 | 状态 |
|-----|-------|------|
| users | 1 | ✅ |
| categories | 1 | ✅ |
| scenes | 1 | ✅ |
| materials | 0 | ✅ (等待上传) |
| system_settings | 1 | ✅ |
| material_likes | 0 | ✅ |
---
## 🔐 登录信息
### 管理员账户
```
用户名: admin
邮箱: admin
密码: admin123456
角色: ADMIN
```
⚠️ **重要**: 首次登录后请立即修改密码!
---
## 🌐 访问地址
### 内网访问
- **网站首页**: http://192.168.58.20:4320
- **登录页面**: http://192.168.58.20:4320/login
- **管理后台**: http://192.168.58.20:4320/admin
- **系统设置**: http://192.168.58.20:4320/admin/settings
### 公网访问
通过frp穿透访问(配置在180.97.221.140)
---
## ✨ 新功能清单
### 1. 多类型素材支持
- ✅ 支持类型:3DMAX / PSD / AI / CAD / C4D / 其他
- ✅ 条件渲染:仅3DMAX显示渲染器选择
- ✅ 卡片展示:在首页显示素材类型
### 2. 系统设置(外观设置)
- ✅ 自定义顶部导航栏背景色
- ✅ 自定义网站LOGO(支持上传到S3)
- ✅ 自定义网站名称
- ✅ 实时预览效果
### 3. 数据库架构
- ✅ materials表新增materialType字段
- ✅ 新增system_settings表
- ✅ 所有索引已创建
- ✅ 外键约束已配置
---
## 🎯 功能测试清单
### 基础功能
- [ ] 用户登录/注册
- [ ] 素材浏览
- [ ] 素材搜索/筛选
- [ ] 素材下载
- [ ] 素材点赞
### 管理功能
- [ ] 用户管理
- [ ] 素材上传(包含新类型)
- [ ] 素材编辑
- [ ] 素材删除
- [ ] 系统设置(外观)
### 新增功能
- [ ] 素材类型选择(上传时)
- [ ] 渲染器类型条件显示
- [ ] 自定义LOGO上传
- [ ] 自定义网站名称
- [ ] 自定义导航栏颜色
---
## 📝 下一步操作
### 立即执行
1. ✅ 服务已启动
2. ✅ 数据库已迁移
3. 🔲 登录并修改admin密码
4. 🔲 配置系统外观设置
5. 🔲 添加常用分类和场景
### 数据恢复
1. 🔲 通知用户开始重新上传素材
2. 🔲 本地325个压缩包文件需要重新上传
3. 🔲 S3上的334个预览图将自动关联
### 系统优化
1. 🔲 配置自动备份(建议每日备份)
2. 🔲 设置监控告警
3. 🔲 优化上传文件大小限制
4. 🔲 配置CDN加速(如需要)
---
## 🛠️ 运维命令
### 查看服务状态
```bash
ssh -p 2322 root@180.97.221.140
cd /mnt/PoolOne/apps/youyidian-3d
docker compose ps
```
### 查看应用日志
```bash
docker logs -f youyidian-3d-material
```
### 重启服务
```bash
docker compose restart
```
### 停止服务
```bash
docker compose down
```
### 重新构建并启动
```bash
docker compose down
docker compose build --no-cache
docker compose up -d
```
### 查看数据库数据
```bash
docker run --rm -it postgres:15-alpine psql postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
```
---
## 📦 备份建议
### 数据库备份
```bash
# 手动备份
docker run --rm postgres:15-alpine pg_dump \
postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat \
> backup_$(date +%Y%m%d_%H%M%S).sql
# 定时备份(添加到crontab)
0 2 * * * cd /mnt/PoolOne/backups && docker run --rm postgres:15-alpine pg_dump \
postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat \
| gzip > db_backup_$(date +\%Y\%m\%d).sql.gz
```
### 文件备份
```bash
# 备份上传的文件
tar -czf uploads_backup_$(date +%Y%m%d).tar.gz /mnt/PoolOne/apps/youyidian-3d/uploads/
```
---
## ⚠️ 注意事项
1. **密码安全**: 当前admin密码为临时密码,请立即修改
2. **数据恢复**: 旧数据已丢失,需要用户重新上传325个素材
3. **备份策略**: 强烈建议配置自动备份,避免数据再次丢失
4. **监控告警**: 建议配置磁盘空间、内存使用等监控
5. **SSL证书**: 如需公网HTTPS访问,需配置SSL证书
---
## 🎊 部署完成!
所有服务已成功部署并正常运行。现在可以:
1. 访问 http://192.168.58.20:4320
2. 使用 admin / admin123456 登录
3. 开始使用系统!
如有任何问题,请检查日志:
```bash
docker logs youyidian-3d-material
```

View File

@ -11,6 +11,7 @@ interface User {
id: string
email: string
username: string
department: string
role: string
isActive: boolean
canDownload: boolean
@ -637,6 +638,9 @@ export default function AdminPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
邮箱
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
部门
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
角色
</th>
@ -668,6 +672,11 @@ export default function AdminPage() {
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{user.department || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${

View File

@ -18,6 +18,11 @@ export default function SystemSettings() {
const [logoUrl, setLogoUrl] = useState('')
const [logoPreview, setLogoPreview] = useState('')
// 部门管理状态
const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])
const [newDepartmentName, setNewDepartmentName] = useState('')
const [addingDepartment, setAddingDepartment] = useState(false)
// 权限检查
useEffect(() => {
if (status === 'unauthenticated') {
@ -31,6 +36,7 @@ export default function SystemSettings() {
useEffect(() => {
if (status === 'authenticated' && session?.user?.role === 'ADMIN') {
fetchSettings()
fetchDepartments()
}
}, [status, session])
@ -52,6 +58,73 @@ export default function SystemSettings() {
}
}
const fetchDepartments = async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
setDepartments(data)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}
const handleAddDepartment = async () => {
if (!newDepartmentName.trim()) {
alert('请输入部门名称')
return
}
setAddingDepartment(true)
try {
const response = await fetch('/api/departments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newDepartmentName.trim() }),
})
if (response.ok) {
await fetchDepartments()
setNewDepartmentName('')
alert('部门添加成功')
} else {
const error = await response.json()
alert(error.error || '添加失败')
}
} catch (error) {
console.error('Add department error:', error)
alert('添加失败,请稍后重试')
} finally {
setAddingDepartment(false)
}
}
const handleDeleteDepartment = async (id: string, name: string) => {
if (!confirm(`确定要删除部门"${name}"吗?`)) {
return
}
try {
const response = await fetch(`/api/departments?id=${id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchDepartments()
alert('部门删除成功')
} else {
const error = await response.json()
alert(error.error || '删除失败')
}
} catch (error) {
console.error('Delete department error:', error)
alert('删除失败,请稍后重试')
}
}
const handleSave = async () => {
setSaving(true)
try {
@ -353,6 +426,76 @@ export default function SystemSettings() {
</div>
</div>
{/* 部门管理 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg className="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
部门管理
</h2>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
管理系统中的部门列表,用户注册时可以选择所属部门
</p>
{/* 添加部门 */}
<div className="flex gap-2">
<input
type="text"
value={newDepartmentName}
onChange={(e) => setNewDepartmentName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddDepartment()
}
}}
placeholder="输入部门名称(例如:设计部、技术部等)"
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
disabled={addingDepartment}
/>
<button
onClick={handleAddDepartment}
disabled={addingDepartment}
className="btn-primary whitespace-nowrap disabled:opacity-50"
>
{addingDepartment ? '添加中...' : '添加部门'}
</button>
</div>
{/* 部门列表 */}
<div className="mt-4">
{departments.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无部门,请添加第一个部门
</div>
) : (
<div className="space-y-2">
{departments.map((dept) => (
<div
key={dept.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"
>
<span className="text-gray-900 dark:text-white font-medium">
{dept.name}
</span>
<button
onClick={() => handleDeleteDepartment(dept.id, dept.name)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* 保存按钮 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 flex justify-between items-center">
<button

View File

@ -4,7 +4,7 @@ import bcrypt from 'bcryptjs'
export async function POST(req: NextRequest) {
try {
const { email, username, password } = await req.json()
const { email, username, department, password } = await req.json()
// Validation
if (!email || !username || !password) {
@ -36,12 +36,14 @@ export async function POST(req: NextRequest) {
data: {
email,
username,
department: department || '',
password: hashedPassword,
},
select: {
id: true,
email: true,
username: true,
department: true,
role: true,
createdAt: true,
},

View File

@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// Get all departments
export async function GET() {
try {
const departments = await prisma.department.findMany({
orderBy: {
createdAt: 'asc',
},
})
return NextResponse.json(departments)
} catch (error) {
console.error('Error fetching departments:', error)
return NextResponse.json(
{ error: '获取部门列表失败' },
{ status: 500 }
)
}
}
// Create a new department (admin only)
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
}
const { name } = await req.json()
if (!name || name.trim() === '') {
return NextResponse.json(
{ error: '部门名称不能为空' },
{ status: 400 }
)
}
// Check if department already exists
const existing = await prisma.department.findUnique({
where: { name },
})
if (existing) {
return NextResponse.json(
{ error: '部门已存在' },
{ status: 400 }
)
}
const department = await prisma.department.create({
data: { name },
})
return NextResponse.json(department, { status: 201 })
} catch (error) {
console.error('Error creating department:', error)
return NextResponse.json(
{ error: '创建部门失败' },
{ status: 500 }
)
}
}
// Delete a department (admin only)
export async function DELETE(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少部门ID' },
{ status: 400 }
)
}
await prisma.department.delete({
where: { id },
})
return NextResponse.json({ message: '删除成功' })
} catch (error) {
console.error('Error deleting department:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
}
}

View File

@ -12,6 +12,7 @@ export async function GET(req: NextRequest) {
const categoryName = searchParams.get('category') || ''
const sceneName = searchParams.get('scene') || ''
const month = searchParams.get('month') || ''
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount
const page = parseInt(searchParams.get('page') || '1')
const pageSize = parseInt(searchParams.get('pageSize') || '20')
@ -53,6 +54,12 @@ export async function GET(req: NextRequest) {
const totalCount = await prisma.material.count({ where })
const totalPages = Math.ceil(totalCount / pageSize)
// 构建排序规则
let orderBy: any = { createdAt: 'desc' }
if (sortBy === 'downloadCount') {
orderBy = { downloadCount: 'desc' }
}
// 获取分页数据
const materials = await prisma.material.findMany({
where,
@ -63,9 +70,7 @@ export async function GET(req: NextRequest) {
},
},
},
orderBy: {
createdAt: 'desc',
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
})

View File

@ -0,0 +1,254 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import https from 'https';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// 简单的密码hash函数(临时使用)
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
// 配置
const UPLOADS_DIR = '/app/uploads';
const S3_BUCKET_URL = 'https://s3.1080p.hk/jsxiangche/?max-keys=2000';
const S3_PUBLIC_URL = 'https://s3.1080p.hk/jsxiangche/';
// 默认配置
const ADMIN_EMAIL = 'admin';
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'admin123456';
const DEFAULT_CATEGORY = '默认店铺';
const DEFAULT_SCENE = '默认场景';
const DEFAULT_RENDERER = 'CR';
const DEFAULT_MATERIAL_TYPE = '3DMAX';
// 解析S3 XML
function parseS3XML(xml: string): string[] {
const keys: string[] = [];
const keyRegex = /<Key>([^<]+)<\/Key>/g;
let match;
while ((match = keyRegex.exec(xml)) !== null) {
keys.push(match[1]);
}
return keys;
}
// 获取S3预览图列表
async function getS3PreviewFiles(): Promise<string[]> {
return new Promise((resolve, reject) => {
https.get(S3_BUCKET_URL, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
const allKeys = parseS3XML(data);
const previewFiles = allKeys.filter(key => key.includes('preview-'));
resolve(previewFiles);
});
}).on('error', reject);
});
}
// 获取本地素材文件
function getLocalMaterialFiles(uploadsDir: string): string[] {
try {
const files = fs.readdirSync(uploadsDir);
return files.filter(f =>
f.startsWith('zip-') && (f.endsWith('.zip') || f.endsWith('.rar') || f.endsWith('.7z'))
);
} catch (error: any) {
console.error('读取本地文件失败:', error.message);
return [];
}
}
// 提取时间戳
function extractTimestamp(filename: string): number | null {
const match = filename.match(/-(\d{13})-/);
return match ? parseInt(match[1]) : null;
}
// 提取原始名称
function extractOriginalName(filename: string): string {
const parts = filename.split('-');
if (parts.length >= 3) {
const namePart = parts.slice(2).join('-');
return namePart.replace(/\.(zip|rar|7z)$/i, '');
}
return filename;
}
// 匹配文件
interface Match {
previewFile: string | null;
materialFile: string;
timestamp: number;
}
function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
const matches: Match[] = [];
for (const materialFile of materialFiles) {
const materialTimestamp = extractTimestamp(materialFile);
if (!materialTimestamp) continue;
const matchedPreview = previewFiles.find(previewFile => {
const previewTimestamp = extractTimestamp(previewFile);
if (!previewTimestamp) return false;
return Math.abs(previewTimestamp - materialTimestamp) < 1000;
});
matches.push({
previewFile: matchedPreview || null,
materialFile: materialFile,
timestamp: materialTimestamp
});
}
return matches;
}
export async function POST(req: NextRequest) {
try {
const output: string[] = [];
output.push('🚀 开始数据恢复...\n');
// 创建管理员用户
output.push('📝 步骤1: 创建管理员用户');
let admin;
try {
const hashedPassword = hashPassword(ADMIN_PASSWORD);
admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
username: ADMIN_USERNAME,
password: hashedPassword,
role: 'ADMIN',
isActive: true,
canDownload: true
}
});
output.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
} catch (error: any) {
if (error.code === 'P2002') {
admin = await prisma.user.findUnique({
where: { email: ADMIN_EMAIL }
});
output.push(`ℹ️ 管理员用户已存在`);
} else {
throw error;
}
}
if (!admin) {
throw new Error('无法创建或找到管理员用户');
}
// 创建默认分类
output.push('\n📝 步骤2: 创建默认分类和场景');
try {
await prisma.category.create({ data: { name: DEFAULT_CATEGORY } });
output.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`);
} catch (error: any) {
if (error.code === 'P2002') {
output.push(`ℹ️ 默认分类已存在`);
}
}
// 创建默认场景
try {
await prisma.scene.create({ data: { name: DEFAULT_SCENE } });
output.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`);
} catch (error: any) {
if (error.code === 'P2002') {
output.push(`ℹ️ 默认场景已存在`);
}
}
// 获取S3预览图
output.push('\n📝 步骤3: 获取S3预览图列表');
const previewFiles = await getS3PreviewFiles();
output.push(`✅ 从S3找到 ${previewFiles.length} 个预览图`);
// 获取本地素材文件
output.push('\n📝 步骤4: 扫描本地素材文件');
const materialFiles = getLocalMaterialFiles(UPLOADS_DIR);
output.push(`✅ 从本地找到 ${materialFiles.length} 个素材文件`);
// 匹配文件
output.push('\n📝 步骤5: 匹配预览图和素材文件');
const matches = matchFiles(previewFiles, materialFiles);
const withPreview = matches.filter(m => m.previewFile).length;
const withoutPreview = matches.length - withPreview;
output.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
// 恢复素材数据
output.push('\n📝 步骤6: 恢复素材数据到数据库');
const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads';
let successCount = 0;
let failCount = 0;
for (const match of matches) {
try {
const originalName = extractOriginalName(match.materialFile);
const containerPath = path.join(UPLOADS_DIR, match.materialFile);
const hostPath = path.join(HOST_UPLOADS_DIR, match.materialFile);
const stats = fs.statSync(containerPath);
await prisma.material.create({
data: {
previewUrl: match.previewFile ? `${S3_PUBLIC_URL}${match.previewFile}` : '',
productModel: originalName.substring(0, 50),
categoryName: DEFAULT_CATEGORY,
sceneName: DEFAULT_SCENE,
materialType: DEFAULT_MATERIAL_TYPE,
renderer: DEFAULT_RENDERER,
authorName: ADMIN_USERNAME,
zipFileName: match.materialFile,
zipFilePath: hostPath,
zipFileSize: BigInt(stats.size),
downloadCount: 0,
likeCount: 0,
userId: admin.id
}
});
successCount++;
if (successCount % 10 === 0) {
output.push(`✅ 已恢复: ${successCount}/${matches.length}`);
}
} catch (error: any) {
failCount++;
output.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`);
}
}
output.push(`\n📊 恢复完成统计:`);
output.push(` ✅ 成功: ${successCount} 个`);
output.push(` ❌ 失败: ${failCount} 个`);
output.push(` 📁 总计: ${matches.length} 个文件`);
output.push(`\n🎉 数据恢复完成!`);
output.push(`\n📌 默认信息:`);
output.push(` 管理员账号: ${ADMIN_USERNAME}`);
output.push(` 管理员密码: ${ADMIN_PASSWORD}`);
output.push(` 默认分类: ${DEFAULT_CATEGORY}`);
output.push(` 默认场景: ${DEFAULT_SCENE}`);
output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`);
return NextResponse.json({
success: true,
message: output.join('\n'),
stats: { successCount, failCount, total: matches.length }
});
} catch (error: any) {
return NextResponse.json({
success: false,
message: error.message
}, { status: 500 });
}
}

View File

@ -44,6 +44,7 @@ export default function Home() {
const [downloadProgress, setDownloadProgress] = useState(0)
const [selectedMonth, setSelectedMonth] = useState('')
const [months, setMonths] = useState<Array<{ value: string; label: string; count: number }>>([])
const [sortBy, setSortBy] = useState('createdAt') // createdAt | downloadCount
const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null)
const [totalMaterialsCount, setTotalMaterialsCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
@ -51,6 +52,7 @@ export default function Home() {
const [shareUrl, setShareUrl] = useState<string | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showShareNotification, setShowShareNotification] = useState(false)
const [jumpPage, setJumpPage] = useState('')
const pageSize = 20
useEffect(() => {
@ -67,14 +69,14 @@ export default function Home() {
useEffect(() => {
setCurrentPage(1) // 重置到第一页
}, [searchKeyword, selectedCategory, selectedScene, selectedMonth])
}, [searchKeyword, selectedCategory, selectedScene, selectedMonth, sortBy])
useEffect(() => {
if (status === 'authenticated') {
fetchMaterials()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchKeyword, selectedCategory, selectedScene, selectedMonth])
}, [currentPage, searchKeyword, selectedCategory, selectedScene, selectedMonth, sortBy])
const fetchCategories = async () => {
try {
@ -107,6 +109,7 @@ export default function Home() {
if (selectedCategory) params.append('category', selectedCategory)
if (selectedScene) params.append('scene', selectedScene)
if (selectedMonth) params.append('month', selectedMonth)
if (sortBy) params.append('sortBy', sortBy)
params.append('page', currentPage.toString())
params.append('pageSize', pageSize.toString())
@ -479,6 +482,35 @@ export default function Home() {
</div>
</div>
{/* Sort Options (排序方式) */}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">排序方式</h2>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSortBy('createdAt')}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
sortBy === 'createdAt'
? 'bg-gray-900 dark:bg-gray-700 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
最新上传
</button>
<button
onClick={() => setSortBy('downloadCount')}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
sortBy === 'downloadCount'
? 'bg-gray-900 dark:bg-gray-700 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
下载最多
</button>
</div>
</div>
{/* Active Filters Display */}
{(searchKeyword || selectedCategory || selectedScene || selectedMonth) && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-apple p-3 mb-4">
@ -705,72 +737,136 @@ export default function Home() {
{/* 分页组件 */}
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
{/* 上一页 */}
<button
onClick={() => {
setCurrentPage(prev => Math.max(1, prev - 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === 1}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="mt-8 flex flex-col items-center gap-4">
<div className="flex items-center justify-center gap-2">
{/* 首页 */}
<button
onClick={() => {
setCurrentPage(1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === 1}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
首页
</button>
{/* 页码 */}
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum
if (totalPages <= 7) {
pageNum = i + 1
} else if (currentPage <= 4) {
pageNum = i + 1
} else if (currentPage >= totalPages - 3) {
pageNum = totalPages - 6 + i
} else {
pageNum = currentPage - 3 + i
}
{/* 上一页 */}
<button
onClick={() => {
setCurrentPage(prev => Math.max(1, prev - 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === 1}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
return (
<button
key={pageNum}
onClick={() => {
setCurrentPage(pageNum)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className={`px-4 py-2 rounded-lg transition-all ${
currentPage === pageNum
? 'bg-blue-500 text-white font-medium'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{pageNum}
</button>
)
})}
{/* 页码 */}
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum
if (totalPages <= 7) {
pageNum = i + 1
} else if (currentPage <= 4) {
pageNum = i + 1
} else if (currentPage >= totalPages - 3) {
pageNum = totalPages - 6 + i
} else {
pageNum = currentPage - 3 + i
}
return (
<button
key={pageNum}
onClick={() => {
setCurrentPage(pageNum)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className={`px-4 py-2 rounded-lg transition-all ${
currentPage === pageNum
? 'bg-blue-500 text-white font-medium'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{pageNum}
</button>
)
})}
</div>
{/* 下一页 */}
<button
onClick={() => {
setCurrentPage(prev => Math.min(totalPages, prev + 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === totalPages}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* 末页 */}
<button
onClick={() => {
setCurrentPage(totalPages)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === totalPages}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
末页
</button>
</div>
{/* 下一页 */}
<button
onClick={() => {
setCurrentPage(prev => Math.min(totalPages, prev + 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
disabled={currentPage === totalPages}
className="px-4 py-2 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* 页码信息 */}
<span className="ml-4 text-sm text-gray-600 dark:text-gray-400">
第 {currentPage} / {totalPages} 页,共 {totalMaterialsCount} 条
</span>
{/* 页码跳转和信息 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
第 {currentPage} / {totalPages} 页,共 {totalMaterialsCount} 条
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">跳至</span>
<input
type="number"
min="1"
max={totalPages}
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
const page = parseInt(jumpPage)
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
setJumpPage('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
}}
className="w-16 px-2 py-1 text-center rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
placeholder=""
/>
<span className="text-sm text-gray-600 dark:text-gray-400">页</span>
<button
onClick={() => {
const page = parseInt(jumpPage)
if (page >= 1 && page <= totalPages) {
setCurrentPage(page)
setJumpPage('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
className="px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 transition-all"
>
跳转
</button>
</div>
</div>
</div>
)}
</>

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
@ -9,11 +9,29 @@ export default function RegisterPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [department, setDepartment] = useState('')
const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetchDepartments()
}, [])
const fetchDepartments = async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
setDepartments(data)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
@ -36,7 +54,7 @@ export default function RegisterPage() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, username, password }),
body: JSON.stringify({ email, username, department, password }),
})
const data = await response.json()
@ -109,6 +127,29 @@ export default function RegisterPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
部门
</label>
<select
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="input-apple"
>
<option value="">请选择部门</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.name}>
{dept.name}
</option>
))}
</select>
{departments.length === 0 && (
<p className="text-sm text-gray-500 mt-1">
暂无可选部门,请联系管理员添加
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
密码

186
backup-database.sh Normal file
View File

@ -0,0 +1,186 @@
#!/bin/bash
################################################################################
# 由壹点3D素材共享平台 - 数据库自动备份脚本
# 功能:自动备份PostgreSQL数据库(不包含源素材文件)
# 使用:./backup-database.sh
################################################################################
# 配置
BACKUP_DIR="/mnt/PoolOne/backups/youyidian-3d"
DB_HOST="192.168.58.20"
DB_PORT="5432"
DB_NAME="lobechat"
DB_USER="gongxiangpan"
DB_PASS="gongxiangpan123"
KEEP_DAYS=30 # 保留最近30天的备份
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 创建备份目录
create_backup_dirs() {
log_info "创建备份目录..."
mkdir -p "$BACKUP_DIR/database"
mkdir -p "$BACKUP_DIR/logs"
}
# 备份数据库
backup_database() {
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="$BACKUP_DIR/database/db_backup_${timestamp}.sql.gz"
log_info "开始备份数据库..."
# 使用Docker运行pg_dump
docker run --rm \
-e PGPASSWORD="$DB_PASS" \
postgres:15-alpine \
pg_dump \
-h "$DB_HOST" \
-p "$DB_PORT" \
-U "$DB_USER" \
-d "$DB_NAME" \
--no-owner \
--no-acl \
2>/dev/null | gzip > "$backup_file"
if [ $? -eq 0 ] && [ -f "$backup_file" ]; then
local size=$(du -h "$backup_file" | cut -f1)
log_info "数据库备份成功: $backup_file (大小: $size)"
return 0
else
log_error "数据库备份失败"
rm -f "$backup_file"
return 1
fi
}
# 清理旧备份
cleanup_old_backups() {
log_info "清理超过 ${KEEP_DAYS} 天的旧备份..."
# 清理数据库备份
local db_deleted=$(find "$BACKUP_DIR/database" -name "db_backup_*.sql.gz" -mtime +$KEEP_DAYS -delete -print | wc -l)
if [ $db_deleted -gt 0 ]; then
log_info "删除了 $db_deleted 个旧数据库备份"
fi
# 清理旧日志
find "$BACKUP_DIR/logs" -name "backup_*.log" -mtime +$KEEP_DAYS -delete
}
# 生成备份报告
generate_report() {
local report_file="$BACKUP_DIR/logs/backup_$(date +%Y%m%d_%H%M%S).log"
cat > "$report_file" << EOF
================================================================================
数据库备份报告
================================================================================
备份时间: $(date '+%Y-%m-%d %H:%M:%S')
数据库: $DB_NAME@$DB_HOST:$DB_PORT
--- 备份统计 ---
数据库备份数量: $(ls -1 "$BACKUP_DIR/database" 2>/dev/null | wc -l)
最旧备份: $(ls -t "$BACKUP_DIR/database" 2>/dev/null | tail -n 1 | xargs stat -c %y 2>/dev/null | cut -d' ' -f1)
最新备份: $(ls -t "$BACKUP_DIR/database" 2>/dev/null | head -n 1 | xargs stat -c %y 2>/dev/null | cut -d' ' -f1)
--- 磁盘使用 ---
数据库备份占用: $(du -sh "$BACKUP_DIR/database" 2>/dev/null | cut -f1)
总计占用: $(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)
--- 最新5个备份 ---
$(ls -lht "$BACKUP_DIR/database" 2>/dev/null | head -n 6 | tail -n 5)
================================================================================
EOF
log_info "备份报告已生成: $report_file"
}
# 测试数据库连接
test_connection() {
log_info "测试数据库连接..."
docker run --rm \
-e PGPASSWORD="$DB_PASS" \
postgres:15-alpine \
psql \
-h "$DB_HOST" \
-p "$DB_PORT" \
-U "$DB_USER" \
-d "$DB_NAME" \
-c "SELECT 1;" >/dev/null 2>&1
if [ $? -eq 0 ]; then
log_info "数据库连接成功"
return 0
else
log_error "数据库连接失败"
return 1
fi
}
# 主函数
main() {
echo ""
echo "========================================"
echo " 由壹点3D素材 - 数据库备份"
echo "========================================"
echo ""
# 测试连接
if ! test_connection; then
log_error "备份终止:无法连接到数据库"
exit 1
fi
# 创建目录
create_backup_dirs
# 执行备份
local db_status=0
backup_database || db_status=1
# 清理旧备份
cleanup_old_backups
# 生成报告
generate_report
# 汇总结果
echo ""
echo "========================================"
echo " 备份完成"
echo "========================================"
if [ $db_status -eq 0 ]; then
log_info "数据库备份任务成功完成"
exit 0
else
log_error "数据库备份任务失败"
exit 1
fi
}
# 执行主函数
main "$@"

View File

@ -91,17 +91,42 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}
}
// 根据文件扩展名自动识别素材类型
const detectMaterialType = (filename: string): string | null => {
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase()
const typeMap: { [key: string]: string | null } = {
'.max': '3DMAX',
'.psd': 'PSD',
'.ai': 'AI',
'.cad': 'CAD',
'.dwg': 'CAD',
'.c4d': 'C4D',
'.zip': null, // 压缩包需要用户手动选择
'.rar': null,
'.7z': null,
}
return typeMap[ext] ?? '其他'
}
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const validExtensions = ['.zip', '.rar', '.7z']
const validExtensions = ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d']
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
if (!validExtensions.includes(fileExtension)) {
setError('请选择压缩包文件(.zip、.rar 或 .7z)')
setError('请选择有效的文件格式(压缩包:.zip/.rar/.7z 或素材文件:.max/.psd/.ai/.cad/.dwg/.c4d)')
return
}
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError('')
}
}
@ -148,13 +173,20 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
setPreviewUrl(URL.createObjectURL(imgFile))
}
// 处理压缩包文件
const zipFiles = files.filter(f => {
// 处理素材文件(包括压缩包和直接文件)
const materialFiles = files.filter(f => {
const ext = f.name.substring(f.name.lastIndexOf('.')).toLowerCase()
return ['.zip', '.rar', '.7z'].includes(ext)
return ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d'].includes(ext)
})
if (zipFiles.length > 0 && !zipFile) {
setZipFile(zipFiles[0])
if (materialFiles.length > 0 && !zipFile) {
const file = materialFiles[0]
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
}
setError('')
@ -629,10 +661,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
</div>
)}
{/* Zip File */}
{/* Material File */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
压缩包 *
素材文件 *
</label>
<div
ref={dropZoneRef}
@ -648,6 +680,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
<p className="text-sm text-gray-500 dark:text-gray-400">
{formatBytes(zipFile.size)}
</p>
{detectMaterialType(zipFile.name) && (
<p className="text-sm text-green-600 dark:text-green-400">
已自动识别类型: {detectMaterialType(zipFile.name)}
</p>
)}
<button
type="button"
onClick={() => setZipFile(null)}
@ -662,7 +699,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept=".zip,.rar,.7z"
accept=".zip,.rar,.7z,.max,.psd,.ai,.cad,.dwg,.c4d"
className="hidden"
/>
<button
@ -670,10 +707,16 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onClick={() => zipInputRef.current?.click()}
className="btn-secondary"
>
选择压缩包
选择素材文件
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 ZIP、RAR、7Z 格式,无大小限制
支持格式:
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
压缩包(.zip / .rar / .7z)或素材文件(.max / .psd / .ai / .cad / .dwg / .c4d)
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 非压缩包文件会自动识别类型
</p>
</div>
)}

View File

@ -11,11 +11,11 @@ services:
- "4320:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:FastBuildAI2025SecureDB@149.88.95.87:5432/lobechat
- DATABASE_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
- DATABASE_DRIVER=node
- KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=
- VECTOR_DB=pgvector
- PGVECTOR_DB_URL=postgresql://postgres:FastBuildAI2025SecureDB@149.88.95.87:5432/lobechat
- PGVECTOR_DB_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=https://s3.1080p.hk
- S3_BUCKET=jsxiangche

View File

@ -16,6 +16,7 @@ model User {
email String @unique
username String @unique
password String
department String @default("") // 用户部门
role Role @default(USER)
isActive Boolean @default(false) // 是否激活,默认未激活
canDownload Boolean @default(true) // 是否允许下载,默认允许
@ -101,6 +102,14 @@ model SystemSettings {
@@map("system_settings")
}
model Department {
id String @id @default(uuid())
name String @unique // 部门名称
createdAt DateTime @default(now())
@@map("departments")
}
enum Role {
USER
ADMIN

304
restore-data.js Normal file
View File

@ -0,0 +1,304 @@
const { PrismaClient } = require('@prisma/client');
const fs = require('fs');
const path = require('path');
const https = require('https');
const crypto = require('crypto');
// 简单的密码hash函数(临时使用,建议后续修改密码)
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
const prisma = new PrismaClient();
// 配置(容器内路径)
const UPLOADS_DIR = '/app/uploads';
const S3_BUCKET_URL = 'https://s3.1080p.hk/jsxiangche/?max-keys=2000';
const S3_PUBLIC_URL = 'https://s3.1080p.hk/jsxiangche/';
// 默认配置
const DEFAULT_CATEGORY = '默认店铺';
const DEFAULT_SCENE = '默认场景';
const DEFAULT_RENDERER = 'CR';
const DEFAULT_MATERIAL_TYPE = '3DMAX';
const ADMIN_EMAIL = 'admin@youyidian.com';
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'admin123456';
// 解析S3 XML响应
function parseS3XML(xml) {
const keys = [];
const keyRegex = /<Key>([^<]+)<\/Key>/g;
let match;
while ((match = keyRegex.exec(xml)) !== null) {
keys.push(match[1]);
}
return keys;
}
// 获取S3中的预览图列表
async function getS3PreviewFiles() {
return new Promise((resolve, reject) => {
https.get(S3_BUCKET_URL, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
console.log(`📊 S3响应大小: ${data.length} 字节`);
const allKeys = parseS3XML(data);
console.log(`📊 解析到 ${allKeys.length} 个总文件`);
const previewFiles = allKeys.filter(key => key.includes('preview-'));
console.log(`✅ 从S3找到 ${previewFiles.length} 个预览图`);
resolve(previewFiles);
});
}).on('error', reject);
});
}
// 获取本地上传的素材文件
function getLocalMaterialFiles(uploadsDir) {
try {
const files = fs.readdirSync(uploadsDir);
const materialFiles = files.filter(f =>
f.startsWith('zip-') && (f.endsWith('.zip') || f.endsWith('.rar') || f.endsWith('.7z'))
);
console.log(`✅ 从本地找到 ${materialFiles.length} 个素材文件`);
return materialFiles;
} catch (error) {
console.error('❌ 读取本地文件失败:', error.message);
return [];
}
}
// 提取时间戳
function extractTimestamp(filename) {
const match = filename.match(/-(\d{13})-/);
return match ? parseInt(match[1]) : null;
}
// 从文件名提取原始名称
function extractOriginalName(filename) {
// zip-1760682783914-SZ0168+策划+涂涂(欧意朗)+王异主图1.7z
const parts = filename.split('-');
if (parts.length >= 3) {
const namePart = parts.slice(2).join('-');
return namePart.replace(/\.(zip|rar|7z)$/i, '');
}
return filename;
}
// 匹配预览图和素材文件
function matchFiles(previewFiles, materialFiles) {
const matches = [];
let withPreview = 0;
let withoutPreview = 0;
let skipped = 0;
for (const materialFile of materialFiles) {
const materialTimestamp = extractTimestamp(materialFile);
if (!materialTimestamp) {
console.log(`⚠️ 无法提取时间戳: ${materialFile}`);
skipped++;
continue;
}
// 查找时间戳相近的预览图(1秒内)
const matchedPreview = previewFiles.find(previewFile => {
const previewTimestamp = extractTimestamp(previewFile);
if (!previewTimestamp) return false;
return Math.abs(previewTimestamp - materialTimestamp) < 1000;
});
// 无论是否有预览图,都创建记录
matches.push({
previewFile: matchedPreview || null,
materialFile: materialFile,
timestamp: materialTimestamp
});
if (matchedPreview) {
withPreview++;
} else {
withoutPreview++;
}
}
console.log(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
return matches;
}
// 创建管理员用户
async function createAdminUser() {
try {
const hashedPassword = hashPassword(ADMIN_PASSWORD);
const admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
username: ADMIN_USERNAME,
password: hashedPassword,
role: 'ADMIN',
isActive: true,
canDownload: true
}
});
console.log(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
return admin;
} catch (error) {
if (error.code === 'P2002') {
const admin = await prisma.user.findUnique({
where: { email: ADMIN_EMAIL }
});
console.log(`ℹ️ 管理员用户已存在`);
return admin;
}
throw error;
}
}
// 创建默认分类
async function createDefaultCategory() {
try {
const category = await prisma.category.create({
data: { name: DEFAULT_CATEGORY }
});
console.log(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`);
return category;
} catch (error) {
if (error.code === 'P2002') {
const category = await prisma.category.findFirst({
where: { name: DEFAULT_CATEGORY }
});
console.log(`ℹ️ 默认分类已存在`);
return category;
}
throw error;
}
}
// 创建默认场景
async function createDefaultScene() {
try {
const scene = await prisma.scene.create({
data: { name: DEFAULT_SCENE }
});
console.log(`✅ 创建默认场景: ${DEFAULT_SCENE}`);
return scene;
} catch (error) {
if (error.code === 'P2002') {
const scene = await prisma.scene.findFirst({
where: { name: DEFAULT_SCENE }
});
console.log(`ℹ️ 默认场景已存在`);
return scene;
}
throw error;
}
}
// 恢复素材数据
async function restoreMaterials(matches, adminUserId, uploadsDir) {
let successCount = 0;
let failCount = 0;
// 容器外的实际路径(用于数据库存储)
const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads';
for (const match of matches) {
try {
const originalName = extractOriginalName(match.materialFile);
const containerPath = path.join(uploadsDir, match.materialFile);
const hostPath = path.join(HOST_UPLOADS_DIR, match.materialFile);
const stats = fs.statSync(containerPath);
const material = await prisma.material.create({
data: {
previewUrl: match.previewFile ? `${S3_PUBLIC_URL}${match.previewFile}` : '',
productModel: originalName.substring(0, 50), // 作为SPU
categoryName: DEFAULT_CATEGORY,
sceneName: DEFAULT_SCENE,
materialType: DEFAULT_MATERIAL_TYPE,
renderer: DEFAULT_RENDERER,
authorName: ADMIN_USERNAME,
zipFileName: match.materialFile,
zipFilePath: hostPath, // 保存宿主机路径
zipFileSize: BigInt(stats.size),
downloadCount: 0,
likeCount: 0,
userId: adminUserId
}
});
successCount++;
console.log(`✅ [${successCount}/${matches.length}] 恢复: ${originalName}`);
} catch (error) {
failCount++;
console.error(`❌ 恢复失败: ${match.materialFile}`, error.message);
}
}
return { successCount, failCount };
}
// 主函数
async function main() {
console.log('🚀 开始数据恢复...\n');
try {
// 1. 创建管理员用户
console.log('📝 步骤1: 创建管理员用户');
const admin = await createAdminUser();
console.log('');
// 2. 创建默认分类和场景
console.log('📝 步骤2: 创建默认分类和场景');
await createDefaultCategory();
await createDefaultScene();
console.log('');
// 3. 获取预览图列表
console.log('📝 步骤3: 获取S3预览图列表');
const previewFiles = await getS3PreviewFiles();
console.log('');
// 4. 获取本地素材文件
console.log('📝 步骤4: 扫描本地素材文件');
const materialFiles = getLocalMaterialFiles(UPLOADS_DIR);
console.log('');
// 5. 匹配文件
console.log('📝 步骤5: 匹配预览图和素材文件');
const matches = matchFiles(previewFiles, materialFiles);
console.log('');
// 6. 恢复数据
console.log('📝 步骤6: 恢复素材数据到数据库');
const { successCount, failCount } = await restoreMaterials(matches, admin.id, UPLOADS_DIR);
console.log('');
// 7. 统计
console.log('📊 恢复完成统计:');
console.log(` ✅ 成功: ${successCount} 个`);
console.log(` ❌ 失败: ${failCount} 个`);
console.log(` 📁 总计: ${materialFiles.length} 个文件`);
console.log('');
console.log('🎉 数据恢复完成!');
console.log('');
console.log('📌 默认信息:');
console.log(` 管理员账号: ${ADMIN_USERNAME}`);
console.log(` 管理员密码: ${ADMIN_PASSWORD}`);
console.log(` 默认分类: ${DEFAULT_CATEGORY}`);
console.log(` 默认场景: ${DEFAULT_SCENE}`);
console.log('');
console.log('⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息');
} catch (error) {
console.error('❌ 恢复过程出错:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
main().catch(console.error);

44
restore-data.sql Normal file
View File

@ -0,0 +1,44 @@
-- 由壹点3D素材共享平台数据恢复SQL脚本
-- 生成时间: 2025-10-26
-- Step 1: 创建管理员用户
INSERT INTO users (id, email, username, password, role, "isActive", "canDownload", "createdAt", "updatedAt")
VALUES (
'fa78e5d3-1ac4-48cc-be3b-78547f4ff9ed',
'admin',
'admin',
'c7ad44cbad762a5da0a452f9e854fdc1e0e7a52a38015f23f3eab1d80b931dd4', -- SHA256 of 'admin123456'
'ADMIN',
true,
true,
NOW(),
NOW()
)
ON CONFLICT (email) DO NOTHING;
-- Step 2: 创建默认分类
INSERT INTO categories (id, name, "createdAt")
VALUES (
gen_random_uuid(),
'默认店铺',
NOW()
)
ON CONFLICT (name) DO NOTHING;
-- Step 3: 创建默认场景
INSERT INTO scenes (id, name, "createdAt")
VALUES (
gen_random_uuid(),
'默认场景',
NOW()
)
ON CONFLICT (name) DO NOTHING;
-- Step 4: 显示当前数据统计
SELECT
'Before restore' as status,
(SELECT COUNT(*) FROM users) as user_count,
(SELECT COUNT(*) FROM categories) as category_count,
(SELECT COUNT(*) FROM scenes) as scene_count,
(SELECT COUNT(*) FROM materials) as material_count;

34219
聊天记录7.md Normal file

File diff suppressed because it is too large Load Diff