WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

554 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
} catch (error) {
console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', error)
}
return value ? [value] : []
}
const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([
'renderer',
'brandName',
'seriesName',
'skuCode',
'materialTexture',
'renderTime',
'imageResolution',
'colorMode',
'productModel',
'platformName',
'categoryName',
'sceneName',
'authorName',
])
// Get all materials with search and filter support
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const { searchParams } = new URL(req.url)
const search = searchParams.get('search') || ''
const categoryName = searchParams.get('category') || ''
const sceneName = searchParams.get('scene') || ''
const materialType = searchParams.get('materialType') || ''
const platformName = searchParams.get('platform') || ''
const month = searchParams.get('month') || ''
const templateId = searchParams.get('templateId') || ''
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount | likeCount
const page = parseInt(searchParams.get('page') || '1')
const pageSize = parseInt(searchParams.get('pageSize') || '20')
const showDeleted = searchParams.get('showDeleted') || ''
const year = searchParams.get('year') || ''
const dynamicFieldFilters: Array<{ key: string; value: string }> = []
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) {
dynamicFieldFilters.push({ key: fieldKey, value })
}
}
})
// Build where clause
const where: any = {}
const andConditions: any[] = []
// 根据showDeleted参数决定是否显示已删除的素材
// 注意:如果 deletedAt 字段不存在(迁移未完成),此查询可能会失败
// 需要先运行数据库迁移:docker exec flnxi-material npx prisma migrate deploy
if (showDeleted === 'true') {
// 只显示已删除的素材
andConditions.push({ deletedAt: { not: null } })
} else {
// 默认只查询未删除的素材
andConditions.push({ deletedAt: null })
}
// Add search condition (search in SPU, shop, scene category, author)
if (search) {
andConditions.push({
OR: [
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
]
})
}
// Add shop filter (使用店铺)
if (categoryName) {
andConditions.push({ categoryName })
}
// Add scene category filter (场景类别)
if (sceneName) {
andConditions.push({ sceneName })
}
// Add material type filter (素材类型)
if (materialType) {
andConditions.push({ materialType })
}
// Add platform filter (平台分类)
if (platformName) {
andConditions.push({ platformName })
}
// Add month filter (上传月份)
if (month) {
const [year, monthNum] = month.split('-')
const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1)
const endDate = new Date(parseInt(year), parseInt(monthNum), 1)
andConditions.push({
createdAt: {
gte: startDate,
lt: endDate
}
})
} else if (year) {
const parsedYear = parseInt(year, 10)
if (!Number.isNaN(parsedYear)) {
andConditions.push({
createdAt: {
gte: new Date(parsedYear, 0, 1),
lt: new Date(parsedYear + 1, 0, 1),
},
})
}
}
// Add template filter (模板筛选)
if (templateId) {
// 如果是预制模板,需要根据模板名称匹配
if (templateId.startsWith('builtin-')) {
const templateKey = templateId.replace('builtin-', '')
// 预制模板通过materialType匹配(因为预制模板的materialType是固定的)
const materialTypeMap: Record<string, string> = {
'3dmax-material': '3DMAX',
'psd-material': 'PSD',
'audio-material': '音频',
'video-material': '视频',
'model-material': '模特',
'other-material': '其他',
}
const mappedMaterialType = materialTypeMap[templateKey]
if (mappedMaterialType) {
// 筛选使用该materialType的素材,且templateId为null或builtin-xxx
andConditions.push({
AND: [
{ materialType: mappedMaterialType },
{
OR: [
{ templateId: null },
{ templateId: templateId },
]
}
]
})
}
} else {
// 自定义模板,直接使用templateId
andConditions.push({ templateId })
}
}
dynamicFieldFilters.forEach(({ key, value }) => {
andConditions.push({ [key]: value } as Record<string, string>)
})
// 合并所有条件
if (andConditions.length > 0) {
where.AND = andConditions
}
// 构建排序规则
let orderBy: any = { createdAt: 'desc' }
if (sortBy === 'downloadCount') {
orderBy = { downloadCount: 'desc' }
} else if (sortBy === 'likeCount') {
orderBy = { likeCount: 'desc' }
}
// 计算总数和分页数据(并行查询,优化性能)
const maxPageSize = Math.min(pageSize, 100) // 限制最大分页大小为 100
const [totalCount, rawMaterials] = await Promise.all([
prisma.material.count({ where }),
prisma.material.findMany({
where,
select: {
id: true,
productModel: true,
materialKind: true,
brandName: true,
seriesName: true,
skuCode: true,
materialTexture: true,
categoryName: true,
sceneName: true,
platformName: true,
materialType: true,
renderer: true,
renderTime: true,
imageResolution: true,
colorMode: true,
// @ts-ignore - 音频字段已在数据库中添加
audioDuration: true,
// @ts-ignore
audioCategory: true,
// @ts-ignore
audioScene: true,
// @ts-ignore
audioUrl: true,
authorName: true,
previewUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
userId: true,
isPremium: true,
templateId: true,
template: {
select: {
name: true,
},
},
user: {
select: {
username: true,
avatar: true,
},
},
},
orderBy,
skip: (page - 1) * maxPageSize,
take: maxPageSize,
})
])
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()
if (session && materials.length > 0) {
const userId = (session.user as any).id
const materialIds = materials.map(m => m.id)
const [likes, favorites] = await Promise.all([
prisma.materialLike.findMany({
where: {
userId,
materialId: { in: materialIds }
},
select: {
materialId: true
}
}),
prisma.materialFavorite.findMany({
where: {
userId,
materialId: { in: materialIds }
},
select: {
materialId: true
}
})
])
userLikes = new Set(likes.map(l => l.materialId))
userFavorites = new Set(favorites.map(f => f.materialId))
}
// 添加点赞状态到每个素材,并转换BigInt为字符串,并修正存储 URL
const materialsWithLikeStatus = materials.map((material) => {
const baseMaterial = {
...material,
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({
materials: materialsWithLikeStatus,
totalPages,
totalCount,
currentPage: page,
})
} catch (error) {
console.error('[Materials] GET error:', error)
return NextResponse.json(
{ error: '获取素材列表失败' },
{ status: 500 }
)
}
}
// Delete material (admin only) - 软删除
// 权限规则:
// - 超级管理员(isSuperAdmin=true)可以删除任意素材
// - 普通管理员(role=ADMIN)可以删除自己上传的素材,以及自己管理的部门成员上传的素材
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')
const permanent = searchParams.get('permanent') === 'true' // 永久删除
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
// 获取当前操作者信息
const operatorId = (session.user as any).id
const isSuperAdmin = Boolean((session.user as any).isSuperAdmin)
// 获取操作者的部门管理信息
const operator = await prisma.user.findUnique({
where: { id: operatorId },
select: {
id: true,
department: true,
managedDepartment: true,
},
})
// Get material to delete files, including uploader's department info
const material = await prisma.material.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 获取用户ID用于日志记录
const userId = operatorId
// 权限验证
// 超级管理员可以删除任意素材
if (!isSuperAdmin) {
// 检查是否是自己上传的素材
const isOwnMaterial = material.userId === operatorId
if (!isOwnMaterial) {
// 检查是否有权限管理素材上传者所在的部门
const operatorScope = (operator?.managedDepartment || operator?.department || '').trim()
const targetDepartment = (material.user?.department || '').trim()
if (!operatorScope) {
return NextResponse.json(
{ error: '您只能删除自己上传的素材' },
{ status: 403 }
)
}
// 检查目标素材的上传者是否在管理范围内
let hasAccess = targetDepartment === operatorScope ||
targetDepartment.startsWith(`${operatorScope}-`)
if (!hasAccess) {
// 使用部门层级关系检查
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap)
}
if (!hasAccess) {
return NextResponse.json(
{ error: '您只能删除自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
}
if (permanent) {
// 永久删除前记录日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: true,
},
},
})
// 收集所有需要删除的文件URL
const filesToDelete: string[] = []
// 源文件(zipFilePath 或 videoSourceUrl)
if (material.zipFilePath) filesToDelete.push(material.zipFilePath)
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl)
}
// 预览图
if (material.previewUrl) filesToDelete.push(material.previewUrl)
// 多预览图(JSON数组)
if (material.previewUrls) {
try {
const previewUrls = JSON.parse(material.previewUrls)
if (Array.isArray(previewUrls)) {
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 视频相关文件
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
// 展示图片(JSON数组)
if (material.displayImages) {
try {
const displayImages = JSON.parse(material.displayImages)
if (Array.isArray(displayImages)) {
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 去重并过滤空值
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
// 删除所有文件(S3或本地)
let deletedFiles = 0
let failedFiles = 0
for (const fileUrl of uniqueFiles) {
try {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted S3 file: ${fileUrl}`)
} else {
// 本地文件(如果存在)
const { unlink } = await import('fs/promises')
try {
await unlink(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted local file: ${fileUrl}`)
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
console.error(`[Delete] Failed to delete local file: ${fileUrl}`, fileError)
failedFiles++
}
}
}
} catch (error) {
console.error(`[Delete] Failed to delete file: ${fileUrl}`, error)
failedFiles++
}
}
console.log(`[Delete] Material ${id} files cleanup: ${deletedFiles} deleted, ${failedFiles} failed`)
// 永久删除数据库记录
await prisma.material.delete({
where: { id },
})
} else {
// 软删除:设置 deletedAt
await prisma.material.update({
where: { id },
data: { deletedAt: new Date() },
})
// 记录软删除日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: false,
},
},
})
}
return NextResponse.json({ message: '删除成功' })
} catch (error) {
console.error('[Materials] DELETE error:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
}
}