反编译恢复源码:阶段1 - 核心API和工具库
- 反编译 app/api/materials/route.ts (素材列表和删除API) - 反编译 app/api/materials/stats/route.ts (统计API) - 新增 lib/material-log.ts (素材操作日志工具) - 新增 lib/utils.ts (URL规范化工具) 这是从生产系统2.2.0版本编译代码反编译恢复的第一批文件
This commit is contained in:
parent
134abfc0a3
commit
29115dddb8
@ -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<string, string[]> = {
|
||||
'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<string> = 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<string>();
|
||||
const favoritedSet = new Set<string>();
|
||||
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, number>()
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// 按月份统计
|
||||
const monthMap = new Map<string, number>();
|
||||
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
423
components/UpdateLogModal.tsx
Normal file
423
components/UpdateLogModal.tsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div className="relative w-full max-w-2xl max-h-[85vh] bg-white dark:bg-gray-800 rounded-2xl shadow-apple-lg overflow-hidden transform transition-all flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-white dark:from-gray-800 dark:to-gray-900 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{showAllHistory ? '更新记录' : updateLog?.title || '系统更新'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{showAllHistory ? '查看所有历史更新' : `版本 ${updateLog?.version || ''} · ${updateLog?.date || ''}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="px-6 py-6 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="space-y-6">
|
||||
{logsToShow.map((log, logIndex) => (
|
||||
<div key={logIndex} className="space-y-4">
|
||||
{/* 版本标题(仅在显示所有历史时显示) */}
|
||||
{showAllHistory && (
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 pb-2 border-b border-gray-200 dark:border-gray-700 z-10 mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
版本 {log.version}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.date}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新项列表 */}
|
||||
<div className="space-y-4">
|
||||
{log.items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className={`p-4 rounded-xl border ${getTypeColor(item.type)} transition-all hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl flex-shrink-0">{getTypeIcon(item.type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1.5">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="px-6 py-5 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 px-6 bg-gray-900 dark:bg-gray-700 text-white rounded-xl font-medium hover:bg-gray-800 dark:hover:bg-gray-600 active:scale-[0.98] transition-all duration-200 shadow-apple hover:shadow-apple-lg"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Hook: 检查是否需要显示更新弹窗
|
||||
export function useUpdateLog() {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [currentLog, setCurrentLog] = useState<UpdateLog | null>(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
|
||||
|
||||
111
lib/material-log.ts
Normal file
111
lib/material-log.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
158
lib/utils.ts
Normal file
158
lib/utils.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user