diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 81e7f23..83707b3 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -1,180 +1,665 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { deleteFromS3, isS3Url } from '@/lib/s3'; +import { createMaterialLog } from '@/lib/material-log'; +import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; -// 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 sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount - const page = parseInt(searchParams.get('page') || '1') - const pageSize = parseInt(searchParams.get('pageSize') || '20') - - // Build where clause - const where: any = {} - - // Add search condition (search in SPU, shop, scene category, author) - if (search) { - where.OR = [ - { productModel: { contains: search, mode: 'insensitive' } }, // SPU - { categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺 - { sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别 - { authorName: { contains: search, mode: 'insensitive' } }, // 作者 - ] - } - - // Add shop filter (使用店铺) - if (categoryName) { - where.categoryName = categoryName - } - - // Add scene category filter (场景类别) - if (sceneName) { - where.sceneName = sceneName - } - - // Add material type filter (素材类型) - if (materialType) { - where.materialType = materialType - } - - // Add platform filter (平台分类) - if (platformName) { - where.platformName = 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) - where.createdAt = { - gte: startDate, - lt: endDate +// 解析 displayImages 字段 +function parseDisplayImages(displayImages: any): string[] { + if (!displayImages) return []; + + if (Array.isArray(displayImages)) { + return displayImages.filter(img => typeof img === 'string' && img.trim().length > 0); + } + + if (typeof displayImages === 'string') { + try { + const parsed = JSON.parse(displayImages); + if (Array.isArray(parsed)) { + return parsed.filter(img => typeof img === 'string' && img.trim().length > 0); } + } catch (e) { + console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', e); } + return displayImages.trim() ? [displayImages] : []; + } + + return []; +} - // 计算总数和总页数 - const totalCount = await prisma.material.count({ where }) - const totalPages = Math.ceil(totalCount / pageSize) +// 素材类型映射(兼容旧数据) +const MATERIAL_TYPE_MAP: Record = { + '3D场景': ['3D场景', '3DMAX'], + '3DMAX': ['3D场景', '3DMAX'], + '详情页': ['详情页', 'PSD'], + 'PSD': ['详情页', 'PSD'], + '矢量文件': ['矢量文件', 'AI'], + 'AI': ['矢量文件', 'AI'], + 'C4D模型': ['C4D模型', 'C4D'], + 'C4D': ['C4D模型', 'C4D'], +}; - // 构建排序规则 - let orderBy: any = { createdAt: 'desc' } - if (sortBy === 'downloadCount') { - orderBy = { downloadCount: 'desc' } - } - - // 获取分页数据 - const materials = await prisma.material.findMany({ - where, - include: { - user: { - select: { - username: true, +// GET - 获取素材列表 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const { searchParams } = new URL(request.url); + + // 单个素材查询(通过uploadId) + const uploadId = searchParams.get('uploadId'); + if (uploadId) { + const material = await prisma.material.findFirst({ + where: { + uploadId, + deletedAt: null, + }, + select: { + id: true, + userId: true, + previewUrl: true, + productModel: true, + categoryName: true, + sceneName: true, + materialType: true, + renderer: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + createdAt: true, + uploadId: true, + videoSourceUrl: true, + displayImages: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + user: { + select: { + username: true, + avatar: true, + }, }, }, - }, - orderBy, - skip: (page - 1) * pageSize, - take: pageSize, - }) - - // 如果用户已登录,查询点赞状态 - let userLikes: Set = new Set() + }); + + if (!material) { + return NextResponse.json({ material: null }); + } + + // 检查点赞和收藏状态 + let isLiked = false; + let isFavorited = false; + + if (session) { + const userId = session.user.id; + const materialId = material.id; + + // 检查点赞 + const like = await prisma.materialLike.findFirst({ + where: { userId, materialId }, + }); + isLiked = !!like; + + // 检查收藏 + try { + const favorite = await prisma.materialFavorite.findUnique({ + where: { + materialId_userId: { + materialId, + userId, + }, + }, + }); + isFavorited = !!favorite; + } catch (e: any) { + if (!e?.meta?.table?.includes('material_favorites')) { + console.error('[Materials API] Error fetching favorite:', e); + } + } + } + + const normalizedMaterial = normalizeMaterial({ + ...material, + zipFileSize: material.zipFileSize.toString(), + displayImages: parseDisplayImages(material.displayImages || null), + isLiked, + isFavorited, + user: material.user ? { + ...material.user, + avatar: normalizeUrl(material.user.avatar), + } : null, + }); + + return NextResponse.json({ material: normalizedMaterial }); + } + + // 列表查询 + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + const scene = searchParams.get('scene') || ''; + const materialType = searchParams.get('materialType') || ''; + const platform = searchParams.get('platform') || ''; + const productCategory = searchParams.get('productCategory') || ''; + const month = searchParams.get('month') || ''; + const sortBy = searchParams.get('sortBy') || 'comprehensive'; + const sortOrder = searchParams.get('sortOrder') || 'desc'; + const page = parseInt(searchParams.get('page') || '1'); + const pageSize = parseInt(searchParams.get('pageSize') || '20'); + + // 构建查询条件 + const where: any = {}; + + // 软删除过滤 + if (searchParams.get('showDeleted') === 'true') { + where.deletedAt = { not: null }; + } else { + where.deletedAt = null; + } + + // 搜索条件 + if (search) { + where.OR = [ + { productModel: { contains: search, mode: 'insensitive' } }, + { categoryName: { contains: search, mode: 'insensitive' } }, + { sceneName: { contains: search, mode: 'insensitive' } }, + { authorName: { contains: search, mode: 'insensitive' } }, + ]; + } + + // 分类筛选 + if (category) { + where.categoryName = category; + } + + if (scene) { + where.sceneName = scene; + } + + // 素材类型筛选(支持映射) + if (materialType) { + const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType]; + if (types.length === 1) { + where.materialType = types[0]; + } else { + where.materialType = { in: types }; + } + } + + if (platform) { + where.platformName = platform; + } + + if (productCategory) { + where.productCategoryName = productCategory; + } + + // 月份筛选 + 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); + where.createdAt = { + gte: startDate, + lt: endDate, + }; + } + + // 获取总数 + let totalCount: number; + try { + totalCount = await prisma.material.count({ where }); + } catch (e: any) { + if (e?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); + const whereWithoutDeleted = { ...where }; + delete whereWithoutDeleted.deletedAt; + totalCount = await prisma.material.count({ where: whereWithoutDeleted }); + } else { + throw e; + } + } + + const totalPages = Math.ceil(totalCount / pageSize); + + // 排序 + const orderBy: any = { createdAt: sortOrder }; + if (sortBy === 'downloadCount') { + orderBy = { downloadCount: sortOrder }; + } + + // 查询素材列表 + let materials: any[]; + const queryWhere = where; + + try { + const query: any = { + where: queryWhere, + select: { + id: true, + userId: true, + previewUrl: true, + productModel: true, + categoryName: true, + sceneName: true, + materialType: true, + renderer: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + displayImages: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + transcodeLastError: true, + transcodeAttempts: true, + user: { + select: { + username: true, + avatar: true, + }, + }, + }, + }; + + // 综合排序不分页 + if (sortBy !== 'comprehensive') { + query.orderBy = orderBy; + query.skip = (page - 1) * pageSize; + query.take = pageSize; + } + + materials = await prisma.material.findMany(query); + } catch (e: any) { + if (e?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); + const whereWithoutDeleted = { ...queryWhere }; + delete whereWithoutDeleted.deletedAt; + + try { + materials = await prisma.material.findMany({ + where: whereWithoutDeleted, + select: { + id: true, + userId: true, + previewUrl: true, + productModel: true, + categoryName: true, + sceneName: true, + materialType: true, + renderer: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + displayImages: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + transcodeLastError: true, + transcodeAttempts: true, + user: { + select: { + username: true, + avatar: true, + }, + }, + }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + }); + } catch (e2: any) { + if (e2?.meta?.column?.includes('transcode')) { + console.warn('[Materials API] Transcode fields not found, using fallback query'); + materials = (await prisma.material.findMany({ + where: whereWithoutDeleted, + select: { + id: true, + userId: true, + previewUrl: true, + productModel: true, + categoryName: true, + sceneName: true, + materialType: true, + renderer: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + displayImages: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + user: { + select: { + username: true, + }, + }, + }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + })).map(m => ({ + ...m, + transcodeLastError: null, + transcodeAttempts: 0, + })); + } else { + throw e2; + } + } + } else if (e?.meta?.column?.includes('transcode')) { + console.warn('[Materials API] Transcode fields not found, using fallback query'); + materials = (await prisma.material.findMany({ + where: queryWhere, + select: { + id: true, + userId: true, + previewUrl: true, + productModel: true, + categoryName: true, + sceneName: true, + materialType: true, + renderer: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + displayImages: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + user: { + select: { + username: true, + }, + }, + }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + })).map(m => ({ + ...m, + transcodeLastError: null, + transcodeAttempts: 0, + })); + } else { + throw e; + } + } + + // 批量查询点赞和收藏状态 + const likedSet = new Set(); + const favoritedSet = new Set(); + if (session) { - const userId = (session.user as any).id + const userId = session.user.id; + const materialIds = materials.map(m => m.id); + + // 批量查询点赞 const likes = await prisma.materialLike.findMany({ where: { userId, - materialId: { - in: materials.map(m => m.id) - } + materialId: { in: materialIds }, }, - select: { - materialId: true + select: { materialId: true }, + }); + likes.forEach(like => likedSet.add(like.materialId)); + + // 批量查询收藏 + try { + const favorites = await prisma.materialFavorite.findMany({ + where: { + userId, + materialId: { in: materialIds }, + }, + select: { materialId: true }, + }); + favorites.forEach(fav => favoritedSet.add(fav.materialId)); + } catch (e: any) { + if (!e?.meta?.table?.includes('material_favorites')) { + console.error('[Materials API] Error fetching favorites:', e); } - }) - userLikes = new Set(likes.map(l => l.materialId)) + } } - - // 添加点赞状态到每个素材,并转换BigInt为字符串 - const materialsWithLikeStatus = materials.map(material => ({ - ...material, - zipFileSize: material.zipFileSize.toString(), - isLiked: userLikes.has(material.id) - })) - + + // 格式化返回数据 + const formattedMaterials = materials.map(material => { + const formatted = { + ...material, + zipFileSize: material.zipFileSize.toString(), + displayImages: parseDisplayImages(material.displayImages || null), + isLiked: likedSet.has(material.id), + isFavorited: favoritedSet.has(material.id), + user: material.user ? { + ...material.user, + avatar: normalizeUrl(material.user.avatar), + } : null, + }; + return normalizeMaterial(formatted); + }); + return NextResponse.json({ - materials: materialsWithLikeStatus, + materials: formattedMaterials, totalPages, totalCount, currentPage: page, - }) + }); } catch (error) { - console.error('Error fetching materials:', error) + console.error('Error fetching materials:', error); return NextResponse.json( { error: '获取素材列表失败' }, { status: 500 } - ) + ); } } -// Delete material (admin only) -export async function DELETE(req: NextRequest) { +// DELETE - 删除素材 +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { + const session = await getServerSession(authOptions); + + // 权限检查 + if (!session || session.user.role !== 'ADMIN') { return NextResponse.json( { error: '无权限' }, { status: 403 } - ) + ); } - - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') - + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const permanent = searchParams.get('permanent') === 'true'; + if (!id) { return NextResponse.json( { error: '缺少素材ID' }, { status: 400 } - ) + ); } - - // Get material to delete files + + // 查询素材 const material = await prisma.material.findUnique({ where: { id }, - }) - + }); + if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } - ) + ); } - - // Delete from database - await prisma.material.delete({ - where: { id }, - }) - - // TODO: Delete files from S3 and local storage - - return NextResponse.json({ message: '删除成功' }) - } catch (error) { - console.error('Error deleting material:', error) + + const userId = session.user.id; + + // 软删除 + if (!permanent) { + 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: '删除成功(可恢复)' }); + } + + // 永久删除 + { + // 记录日志 + await createMaterialLog({ + materialId: id, + userId, + action: 'DELETE', + changes: { + permanent: { + oldValue: false, + newValue: true, + }, + }, + }); + + // 收集所有文件路径 + const filesToDelete: string[] = []; + + if (material.zipFilePath) { + filesToDelete.push(material.zipFilePath); + } + + if (material.videoSourceUrl && material.videoSourceUrl !== material.zipFilePath) { + filesToDelete.push(material.videoSourceUrl); + } + + if (material.previewUrl) { + filesToDelete.push(material.previewUrl); + } + + if (material.videoPreview540Url) { + filesToDelete.push(material.videoPreview540Url); + } + + if (material.videoPreviewClipUrl) { + filesToDelete.push(material.videoPreviewClipUrl); + } + + if (material.videoCoverUrl) { + filesToDelete.push(material.videoCoverUrl); + } + + if (Array.isArray(material.displayImages) && material.displayImages.length > 0) { + filesToDelete.push(...material.displayImages.filter(img => img && img.trim())); + } + + // 去重 + const uniqueFiles = Array.from(new Set(filesToDelete.filter(f => f && f.trim()))); + + // 删除文件 + let deletedCount = 0; + let failedCount = 0; + + for (const filePath of uniqueFiles) { + try { + if (isS3Url(filePath)) { + await deleteFromS3(filePath); + deletedCount++; + console.log(`[Delete] Deleted S3 file: ${filePath}`); + } else { + // 本地文件 + const { unlink } = await import('fs/promises'); + try { + await unlink(filePath); + deletedCount++; + console.log(`[Delete] Deleted local file: ${filePath}`); + } catch (err: any) { + if (err.code !== 'ENOENT') { + console.error(`[Delete] Failed to delete local file: ${filePath}`, err); + failedCount++; + } + } + } + } catch (err) { + console.error(`[Delete] Failed to delete file: ${filePath}`, err); + failedCount++; + } + } + + console.log(`[Delete] Material ${id} files cleanup: ${deletedCount} deleted, ${failedCount} failed`); + + // 从数据库删除 + await prisma.material.delete({ + where: { id }, + }); + + return NextResponse.json({ + message: '永久删除成功', + deletedFiles: deletedCount, + failedFiles: failedCount, + }); + } + } catch (error: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (error?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials API] deletedAt field not found, using hard delete'); + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (id) { + try { + await prisma.material.delete({ + where: { id }, + }); + return NextResponse.json({ message: '删除成功' }); + } catch (e) { + console.error('Error deleting material:', e); + return NextResponse.json( + { error: '删除失败' }, + { status: 500 } + ); + } + } + } + + console.error('Error deleting material:', error); return NextResponse.json( { error: '删除失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/materials/stats/route.ts b/app/api/materials/stats/route.ts index 44ac8ee..b2ba9fb 100644 --- a/app/api/materials/stats/route.ts +++ b/app/api/materials/stats/route.ts @@ -1,52 +1,52 @@ -import { NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; -// Get material statistics (total count and monthly breakdown) +// GET - 获取素材统计数据 export async function GET() { try { - // Get total count - const total = await prisma.material.count() - - // Get all materials with their creation dates + // 获取总数 + const total = await prisma.material.count(); + + // 获取所有素材的创建时间 const materials = await prisma.material.findMany({ select: { - createdAt: true + createdAt: true, }, orderBy: { - createdAt: 'desc' - } - }) - - // Group by month - const monthMap = new Map() + createdAt: 'desc', + }, + }); + + // 按月份统计 + const monthMap = new Map(); + materials.forEach(material => { - const date = new Date(material.createdAt) - const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - monthMap.set(monthKey, (monthMap.get(monthKey) || 0) + 1) - }) - - // Convert to array and format + const date = new Date(material.createdAt); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + monthMap.set(monthKey, (monthMap.get(monthKey) || 0) + 1); + }); + + // 格式化月份数据 const months = Array.from(monthMap.entries()) .map(([value, count]) => { - const [year, month] = value.split('-') + const [year, month] = value.split('-'); return { value, label: `${year}年${parseInt(month)}月`, - count - } + count, + }; }) - .sort((a, b) => b.value.localeCompare(a.value)) // Sort by date descending - + .sort((a, b) => b.value.localeCompare(a.value)); // 降序排列 + return NextResponse.json({ total, - months - }) + months, + }); } catch (error) { - console.error('Error fetching material stats:', error) + console.error('Error fetching material stats:', error); return NextResponse.json( { error: '获取统计数据失败' }, { status: 500 } - ) + ); } } - diff --git a/components/UpdateLogModal.tsx b/components/UpdateLogModal.tsx new file mode 100644 index 0000000..4497a3b --- /dev/null +++ b/components/UpdateLogModal.tsx @@ -0,0 +1,423 @@ +'use client' + +import { useEffect, useState } from 'react' + +// 更新日志配置 - 每次有新功能或优化时更新这个版本号 +const CURRENT_VERSION = '2.2.1' +const STORAGE_KEY = 'update_log_viewed_version_v221_r1' + +interface UpdateItem { + type: 'feature' | 'improvement' | 'fix' // 新功能、优化、修复 + title: string + description: string +} + +interface UpdateLog { + version: string + date: string + title: string + items: UpdateItem[] +} + +// 更新日志内容 - 通俗易懂,面向客户 +// 注意:只有经过测试验证的功能才会添加到更新记录中 +const UPDATE_LOGS: UpdateLog[] = [ + { + version: '2.2.1', + date: '2025-12-09', + title: '系统更新', + items: [ + { + type: 'feature', + title: '网络诊断功能', + description: '新增网络诊断功能,支持下载和上传测速,自动检测存储服务连通性,结果可复制分享,方便排查网络问题。' + }, + { + type: 'improvement', + title: '下载体验大幅提升', + description: '大文件下载支持断点续传,下载更稳定、速度更快。下载后下载数立即更新,无需刷新页面即可看到最新数据,排行榜面板也会自动刷新。' + }, + { + type: 'improvement', + title: '收藏功能体验优化', + description: '收藏/取消收藏操作后显示气泡提示,操作反馈更清晰。收藏状态在所有素材列表中正确显示,方便快速识别已收藏内容。' + }, + { + type: 'improvement', + title: '素材卡片头像显示优化', + description: '作者头像尺寸增大,改为圆角方形样式,显示更清晰。优化显示逻辑,头像完整显示不被裁切,同时保持卡片高度不变。' + }, + { + type: 'improvement', + title: '个人中心卡片更清晰', + description: '个人中心改为上下大卡片,分开展示"我的素材""我的收藏",信息一目了然。' + }, + { + type: 'improvement', + title: '榜单悬浮窗体验优化', + description: '支持拖动、关闭后出现可拖动的迷你按钮可再打开,位置会记忆。拖拽无延迟,拖动更流畅。' + }, + { + type: 'improvement', + title: '头像加载更稳定', + description: '优化头像加载与处理,解决头像不显示/加载慢的问题,统一压缩为 WebP 格式,优先加载。' + }, + { + type: 'improvement', + title: '转码任务自动恢复', + description: '部署中断后自动恢复转码任务,清理残留文件,避免存储浪费。恢复时保持队列顺序,确保转码任务按顺序执行。' + }, + { + type: 'fix', + title: '登录退出后能正常回到登录页', + description: '修正退出后偶尔跳到 localhost 的问题,现在会正确返回登录页。' + } + ] + }, + { + version: '2.2.0', + date: '2025-12-08', + title: '系统更新', + items: [ + { + type: 'feature', + title: '个人中心上线', + description: '新增“我的素材 / 我的收藏”两个入口,卡片支持预览与视频弹窗,常用信息更聚合。' + }, + { + type: 'feature', + title: '收藏(书签)按钮', + description: '卡片新增“收藏/取消收藏”书签按钮,和点赞清晰区分;收藏列表可在个人中心查看。' + }, + { + type: 'feature', + title: '回收站与批量操作(管理员)', + description: '删除默认进入回收站;支持批量恢复与永久删除,关键操作留痕,误删可挽回。' + }, + { + type: 'feature', + title: '头像上传与即时刷新', + description: '头像选择-预览-上传一体化,提交后标题栏头像即时更新,无需整页刷新。' + }, + { + type: 'feature', + title: '一键复制分享链接', + description: '卡片操作区支持复制分享链接,便于把素材快速发给同事协作。' + }, + { + type: 'improvement', + title: '收藏按钮样式与反馈优化', + description: '采用书签样式,未收藏/已收藏状态对比明显;悬停与点击反馈更清晰。' + }, + { + type: 'improvement', + title: '转码失败一键重试', + description: '在卡片上即可重试转码,结合实时进度提示,处理更直观。' + }, + { + type: 'improvement', + title: '预览视频编码与清晰度升级', + description: '预览视频改用 H.265 (HEVC) 编码,并提升分辨率与质量;画质更清晰。由于分辨率与质量目标提升,网络带宽占用与转码耗时会相应增加(弱网环境建议暂停自动播放)。' + }, + { + type: 'fix', + title: '首页卡片稳定性修复', + description: '修复极端情况下预览区不渲染的问题,提升卡片加载稳定性。' + } + ] + }, + { + version: '2.1.6', + date: '2025-12-06', + title: '系统更新', + items: [ + { + type: 'feature', + title: '素材类型标签', + description: '现在每个素材卡片的右上角都会显示素材类型标签(如"3DMAX"、"视频"等),不同颜色区分,让您一眼就能看出是什么类型的素材。' + }, + { + type: 'feature', + title: '素材ID快速复制', + description: '素材卡片底部现在会显示完整的素材ID,点击ID就能一键复制到剪贴板,再也不用手动选择复制了!复制成功后会显示提示,非常方便。' + }, + { + type: 'feature', + title: '视频素材卡片预览', + description: '视频素材在首页卡片上会自动播放10秒预览片段,转码完成后会显示预览视频而不是静态封面图。还会显示视频时长和转码进度,让您实时了解处理状态。' + }, + { + type: 'improvement', + title: '视频转码速度大幅提升', + description: '优化了存储服务连接方式,视频转码速度更快了!之前转码可能会卡住的问题已经解决,现在转码过程更加稳定可靠。' + }, + { + type: 'feature', + title: '新增"更新记录"菜单', + description: '在头像下拉菜单中新增了"更新记录"入口,您可以随时查看系统所有的历史更新,了解系统都做了哪些改进。' + }, + { + type: 'improvement', + title: '优化复制提示位置', + description: '复制素材ID时的提示消息现在显示在页面顶部居中位置,不会再和标题栏重叠了,提示更加清晰明显。' + } + ] + }, + { + version: '2.1.5', + date: '2025-12-06', + title: '系统更新', + items: [ + { + type: 'feature', + title: '视频转码实时进度显示', + description: '现在上传视频后,可以实时看到转码进度了!进度条会准确显示当前转码状态,让您清楚知道视频处理到哪一步了。' + }, + { + type: 'improvement', + title: '视频上传稳定性大幅提升', + description: '优化了视频上传机制,即使网络不稳定也能可靠完成上传。系统会自动重试,确保您的视频素材不会丢失。' + }, + { + type: 'improvement', + title: '视频预览播放更流畅', + description: '优化了视频预览播放体验,加载速度更快,播放更流畅。现在可以更顺畅地查看和预览视频素材了。' + }, + { + type: 'fix', + title: '修复视频转码卡顿问题', + description: '修复了视频转码过程中可能出现的卡顿问题,现在转码过程更加稳定可靠,不会中途失败。' + }, + { + type: 'feature', + title: '更新日志弹窗', + description: '新增了更新日志功能,每次系统有新功能或优化时,会自动弹出更新说明,让您及时了解系统改进。' + } + ] + } +] + +interface UpdateLogModalProps { + isOpen: boolean + onClose: () => void + updateLog?: UpdateLog // 可选,如果不传则显示所有历史记录 + showAllHistory?: boolean // 是否显示所有历史记录 +} + +function UpdateLogModal({ isOpen, onClose, updateLog, showAllHistory = false }: UpdateLogModalProps) { + useEffect(() => { + if (isOpen) { + // 阻止背景滚动 + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'unset' + } + return () => { + document.body.style.overflow = 'unset' + } + }, [isOpen]) + + if (!isOpen) return null + + // 如果showAllHistory为true,显示所有历史记录;否则只显示指定的updateLog + const logsToShow = showAllHistory ? UPDATE_LOGS : (updateLog ? [updateLog] : []) + + // 如果没有可显示的内容,不渲染弹窗 + if (logsToShow.length === 0) return null + + const getTypeIcon = (type: string) => { + switch (type) { + case 'feature': + return '✨' + case 'improvement': + return '🚀' + case 'fix': + return '🔧' + default: + return '📝' + } + } + + const getTypeColor = (type: string) => { + switch (type) { + case 'feature': + return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800' + case 'improvement': + return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' + case 'fix': + return 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800' + default: + return 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700' + } + } + + return ( +
+ {/* 背景遮罩 */} +
+ + {/* 弹窗内容 */} +
+ {/* 头部 */} +
+
+
+ + + +
+
+

+ {showAllHistory ? '更新记录' : updateLog?.title || '系统更新'} +

+

+ {showAllHistory ? '查看所有历史更新' : `版本 ${updateLog?.version || ''} · ${updateLog?.date || ''}`} +

+
+
+ +
+ + {/* 内容区域 */} +
+
+ {logsToShow.map((log, logIndex) => ( +
+ {/* 版本标题(仅在显示所有历史时显示) */} + {showAllHistory && ( +
+

+ 版本 {log.version} +

+

+ {log.date} +

+
+ )} + + {/* 更新项列表 */} +
+ {log.items.map((item, itemIndex) => ( +
+
+ {getTypeIcon(item.type)} +
+

+ {item.title} +

+

+ {item.description} +

+
+
+
+ ))} +
+
+ ))} +
+
+ + {/* 底部按钮 */} +
+ +
+
+
+ ) +} + +// Hook: 检查是否需要显示更新弹窗 +export function useUpdateLog() { + const [showModal, setShowModal] = useState(false) + const [currentLog, setCurrentLog] = useState(null) + const [showAllHistory, setShowAllHistory] = useState(false) + + useEffect(() => { + // 监听自定义事件,用于手动触发显示更新日志 + const handleShowUpdateLog = (event: CustomEvent) => { + if (event.detail?.log) { + setCurrentLog(event.detail.log) + setShowAllHistory(false) + setShowModal(true) + } else if (event.detail?.showAll) { + setCurrentLog(null) + setShowAllHistory(true) + setShowModal(true) + } + } + + window.addEventListener('showUpdateLog', handleShowUpdateLog as EventListener) + + // 检查是否已查看过当前版本(仅在首次加载时自动弹出) + // 注意:只有经过测试验证的功能更新才会添加到UPDATE_LOGS中 + const viewedVersion = localStorage.getItem(STORAGE_KEY) + + if (viewedVersion !== CURRENT_VERSION) { + // 找到当前版本的更新日志 + const log = UPDATE_LOGS.find(log => log.version === CURRENT_VERSION) + if (log) { + setCurrentLog(log) + setShowAllHistory(false) + setShowModal(true) + } + } + + return () => { + window.removeEventListener('showUpdateLog', handleShowUpdateLog as EventListener) + } + }, []) + + const handleClose = () => { + setShowModal(false) + // 如果显示的是当前版本,标记已查看 + if (!showAllHistory && currentLog?.version === CURRENT_VERSION) { + localStorage.setItem(STORAGE_KEY, CURRENT_VERSION) + } + } + + return { + showModal, + currentLog, + showAllHistory, + handleClose, + } +} + +// 更新日志管理器 - 用于手动触发显示更新日志 +export function showUpdateLog(version?: string) { + if (version === 'all' || version === undefined) { + // 显示所有历史记录 + const event = new CustomEvent('showUpdateLog', { detail: { showAll: true } }) + window.dispatchEvent(event) + } else { + // 显示指定版本 + const log = UPDATE_LOGS.find(log => log.version === version) + if (log) { + const event = new CustomEvent('showUpdateLog', { detail: { log } }) + window.dispatchEvent(event) + } + } +} + +export default UpdateLogModal + diff --git a/lib/material-log.ts b/lib/material-log.ts new file mode 100644 index 0000000..5de7cdf --- /dev/null +++ b/lib/material-log.ts @@ -0,0 +1,111 @@ +import { prisma } from '@/lib/prisma'; + +/** + * 素材操作日志表是否已初始化 + */ +let isInitialized = false; + +/** + * 执行原始 SQL(用于创建表和索引) + */ +async function executeRawSQL(sql: string) { + try { + await prisma.$executeRawUnsafe(sql); + } catch (error) { + console.error('[MaterialLog] Failed to execute SQL:', error); + } +} + +/** + * 初始化素材日志表 + * 如果表不存在,则创建表和索引 + */ +async function initializeMaterialLogTable() { + if (isInitialized) return; + + // 创建表 + await executeRawSQL(` + CREATE TABLE IF NOT EXISTS "material_logs" ( + "id" TEXT PRIMARY KEY, + "materialId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "changes" JSONB, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 创建索引 + await executeRawSQL( + 'CREATE INDEX IF NOT EXISTS "material_logs_materialId_idx" ON "material_logs" ("materialId")' + ); + await executeRawSQL( + 'CREATE INDEX IF NOT EXISTS "material_logs_userId_idx" ON "material_logs" ("userId")' + ); + await executeRawSQL( + 'CREATE INDEX IF NOT EXISTS "material_logs_timestamp_idx" ON "material_logs" ("timestamp")' + ); + + // 添加外键约束 + await executeRawSQL(` + DO $ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'material_logs_materialId_fkey' + ) THEN + ALTER TABLE "material_logs" + ADD CONSTRAINT "material_logs_materialId_fkey" + FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + END + $; + `); + + await executeRawSQL(` + DO $ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'material_logs_userId_fkey' + ) THEN + ALTER TABLE "material_logs" + ADD CONSTRAINT "material_logs_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + END + $; + `); + + isInitialized = true; +} + +/** + * 创建素材操作日志 + */ +export async function createMaterialLog({ + materialId, + userId, + action, + changes = null, +}: { + materialId: string; + userId: string; + action: string; + changes?: any; +}) { + try { + // 确保表已初始化 + await initializeMaterialLogTable(); + + // 创建日志记录 + await prisma.materialLog.create({ + data: { + materialId, + userId, + action, + changes: changes ? JSON.stringify(changes) : null, + }, + }); + } catch (error) { + console.error('[MaterialLog] Failed to create log:', error); + } +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..0b87693 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,158 @@ +/** + * URL 规范化工具 + * 将内网地址转换为公网地址 + */ + +const LOCALHOST_ADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); + +/** + * 获取公网访问配置 + */ +function getPublicAccessConfig() { + const s3PublicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim(); + const serverIp = (process.env.SERVER_IP || '').trim(); + const minioApiPort = (process.env.MINIO_API_PORT || '9000').trim(); + const appPublicUrl = (process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '').trim(); + + let publicHost: string | null = null; + let publicPort = minioApiPort || '9000'; + let protocol = 'http:'; + + // 优先使用 S3_PUBLIC_DOMAIN + if (s3PublicDomain) { + try { + const domainUrl = s3PublicDomain.match(/^https?:\/\//i) + ? s3PublicDomain + : `https://${s3PublicDomain}`; + const url = new URL(domainUrl); + publicHost = url.hostname; + protocol = url.protocol; + publicPort = url.port ? url.port : (protocol === 'https:' ? '443' : '80'); + + // 如果是有效的公网域名,直接返回 + if (publicHost && !LOCALHOST_ADDRESSES.has(publicHost)) { + // 如果是 http 协议,强制改为 https + if (protocol !== 'https:') { + protocol = 'https:'; + if (!url.port) { + publicPort = '443'; + } + } + return { protocol, host: publicHost, port: publicPort }; + } + } catch (e) { + // 解析失败,继续尝试其他配置 + } + } + + // 使用 SERVER_IP + if (serverIp && !LOCALHOST_ADDRESSES.has(serverIp)) { + publicHost = serverIp; + protocol = 'http:'; + publicPort = minioApiPort || '9000'; + } + + // 最后尝试从 APP_PUBLIC_URL 提取 + if (!publicHost && appPublicUrl) { + try { + const url = new URL(appPublicUrl); + if (!LOCALHOST_ADDRESSES.has(url.hostname)) { + publicHost = url.hostname; + protocol = url.protocol; + // 如果 hostname 和 SERVER_IP 相同,使用 MinIO 端口 + if (publicHost === serverIp) { + protocol = 'http:'; + publicPort = minioApiPort || '9000'; + } else { + publicPort = url.port || (protocol === 'https:' ? '443' : '80'); + } + } + } catch (e) { + // 解析失败 + } + } + + if (!publicHost) { + return null; + } + + return { protocol, host: publicHost, port: publicPort }; +} + +/** + * 规范化 URL + * 将内网地址(localhost, 127.0.0.1, 192.168.x.x, 10.x.x.x)转换为公网地址 + */ +export function normalizeUrl(url: string | null | undefined): string | null { + if (!url || typeof url !== 'string') { + return url ?? null; + } + + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + return null; + } + + // 如果不是 HTTP/HTTPS URL,直接返回 + if (!/^https?:\/\//i.test(trimmedUrl)) { + return trimmedUrl; + } + + const publicConfig = getPublicAccessConfig(); + if (!publicConfig) { + return trimmedUrl; + } + + try { + const urlObj = new URL(trimmedUrl); + const isLocalhost = LOCALHOST_ADDRESSES.has(urlObj.hostname); + const isPrivateIp = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(urlObj.hostname); + + // 如果是内网地址,替换为公网地址 + if (isLocalhost || isPrivateIp) { + urlObj.protocol = publicConfig.protocol; + urlObj.hostname = publicConfig.host; + urlObj.port = publicConfig.port; + return urlObj.toString(); + } + + return trimmedUrl; + } catch (e) { + return trimmedUrl; + } +} + +/** + * 规范化素材对象中的所有 URL + */ +export function normalizeMaterial(material: any): any { + if (!material || typeof material !== 'object') { + return material; + } + + const normalized = { ...material }; + + // 规范化所有 URL 字段 + const urlFields = [ + 'previewUrl', + 'videoCoverUrl', + 'videoPreview540Url', + 'videoPreviewClipUrl', + 'videoSourceUrl', + ]; + + urlFields.forEach(field => { + if (field in normalized) { + normalized[field] = normalizeUrl(normalized[field]); + } + }); + + // 规范化 displayImages 数组 + if (Array.isArray(normalized.displayImages)) { + normalized.displayImages = normalized.displayImages.map(img => + typeof img === 'string' ? normalizeUrl(img) : img + ); + } + + return normalized; +}