修复 AudioVisualizer 音频可视化组件:解决 'already connected' 错误,使用全局 WeakMap 跟踪初始化状态;修复 Dockerfile 构建依赖问题

This commit is contained in:
chenpei 2025-12-27 18:54:46 +08:00
parent 29115dddb8
commit 8bf309e996
191 changed files with 34236 additions and 3034 deletions

View File

@ -5,6 +5,19 @@ out
build
dist
# 忽略的项目目录(不属于本项目)
flnxi_sucai
gongxiangp2
frp_*
# Docker 缓存(保留 npm-packages.tgz 用于加速构建)
.docker-cache/*
!.docker-cache/npm-packages.tgz
# 构建缓存(保留 node_modules 缓存)
.build-cache/*
!.build-cache/node_modules.tar.gz
# Git 相关
.git
.gitignore

8
.gitignore vendored
View File

@ -36,3 +36,11 @@ next-env.d.ts
# Uploads
/uploads
# 忽略的项目目录(不属于本项目)
/flnxi_sucai
/gongxiangp2
/frp_*
# Docker 缓存
/.docker-cache

View File

@ -21,15 +21,51 @@ RUN apk add --no-cache \
python3 \
make \
g++ \
pkgconfig
pkgconfig \
vips-dev \
fftw-dev \
build-base
WORKDIR /app
# 复制依赖文件
COPY package.json package-lock.json* ./
RUN npm ci
# 智能缓存策略:
# 1. 如果存在 .build-cache/node_modules.tar.gz,直接解压使用(10-20秒)
# 2. 否则执行 npm ci 下载(60-90秒)
COPY .build-cache/node_modules.tar.gz* /tmp/
RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=locked \
if [ -f /tmp/node_modules.tar.gz ]; then \
echo "✅ 使用缓存的 node_modules(快速模式)" && \
tar -xzf /tmp/node_modules.tar.gz && \
chmod -R +x node_modules/.bin && \
mkdir -p node_modules/canvas && \
echo "🔧 重新安装 sharp(musl 版本)" && \
npm config set registry https://registry.npmmirror.com && \
npm install --os=linux --libc=musl --cpu=x64 --legacy-peer-deps sharp; \
else \
echo "⚠️ 缓存不存在,重新下载依赖(首次构建)" && \
npm config set registry https://registry.npmmirror.com && \
npm config set cache /root/.npm --global && \
npm ci --prefer-offline --no-audit --ignore-optional --legacy-peer-deps && \
mkdir -p node_modules/canvas && \
echo "🔧 安装 sharp(musl 版本)" && \
npm install --os=linux --libc=musl --cpu=x64 --legacy-peer-deps sharp; \
fi
# 构建阶段
FROM base AS builder
# 安装构建时需要的依赖(sharp需要)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk add --no-cache \
openssl3 \
openssl-dev \
vips-dev \
fftw-dev \
build-base \
python3 \
make \
g++
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@ -52,7 +88,7 @@ ENV NEXT_TELEMETRY_DISABLED 1
# 使用阿里云镜像源加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装运行时依赖(包括 canvas 所需的库)
# 安装运行时依赖(包括 canvas 所需的库、FFmpeg 和 Prisma 所需的 OpenSSL)
RUN apk add --no-cache \
openssl3 \
openssl-dev \
@ -61,7 +97,9 @@ RUN apk add --no-cache \
jpeg \
giflib \
librsvg \
pixman
pixman \
ffmpeg \
vips
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@ -71,7 +109,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/canvas ./node_modules/canvas
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import { useToast } from '@/hooks/useToast'
import ToastBubble from '@/components/ToastBubble'
export default function SystemSettings() {
const { data: session, status } = useSession()
@ -21,6 +23,7 @@ export default function SystemSettings() {
const [loginLogoPreview, setLoginLogoPreview] = useState('')
const [uploadingHeader, setUploadingHeader] = useState(false)
const [uploadingLogin, setUploadingLogin] = useState(false)
const { toast, showToast } = useToast()
// S3配置状态
const [s3Endpoint, setS3Endpoint] = useState('')
@ -66,7 +69,7 @@ export default function SystemSettings() {
}
} catch (error) {
console.error('Error fetching settings:', error)
alert('加载设置失败')
showToast('加载设置失败', { variant: 'error' })
} finally {
setLoading(false)
}
@ -96,16 +99,18 @@ export default function SystemSettings() {
})
if (response.ok) {
alert('保存成功!页面将自动刷新以应用新设置')
showToast('保存成功!页面将自动刷新以应用新设置', { variant: 'success', duration: 3000 })
// 刷新页面以应用新设置
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
const error = await response.json()
alert(error.error || '保存失败')
showToast(error.error || '保存失败', { variant: 'error' })
}
} catch (error) {
console.error('Save settings error:', error)
alert('保存失败,请稍后重试')
showToast('保存失败,请稍后重试', { variant: 'error' })
} finally {
setSaving(false)
}
@ -117,13 +122,13 @@ export default function SystemSettings() {
// 验证文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
showToast('请选择图片文件', { variant: 'error' })
return
}
// 验证文件大小(最大5MB)
if (file.size > 5 * 1024 * 1024) {
alert('LOGO文件大小不能超过5MB')
showToast('LOGO文件大小不能超过5MB', { variant: 'error' })
return
}
@ -142,14 +147,14 @@ export default function SystemSettings() {
const data = await response.json()
setHeaderLogoUrl(data.logoUrl)
setHeaderLogoPreview(data.logoUrl)
alert('导航栏LOGO上传成功!')
showToast('导航栏LOGO上传成功!', { variant: 'success' })
} else {
const error = await response.json()
alert(error.error || 'LOGO上传失败')
showToast(error.error || 'LOGO上传失败', { variant: 'error' })
}
} catch (error) {
console.error('Upload logo error:', error)
alert('上传失败,请稍后重试')
showToast('上传失败,请稍后重试', { variant: 'error' })
} finally {
setUploadingHeader(false)
}
@ -161,13 +166,13 @@ export default function SystemSettings() {
// 验证文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件')
showToast('请选择图片文件', { variant: 'error' })
return
}
// 验证文件大小(最大5MB)
if (file.size > 5 * 1024 * 1024) {
alert('LOGO文件大小不能超过5MB')
showToast('LOGO文件大小不能超过5MB', { variant: 'error' })
return
}
@ -186,14 +191,14 @@ export default function SystemSettings() {
const data = await response.json()
setLoginLogoUrl(data.logoUrl)
setLoginLogoPreview(data.logoUrl)
alert('登录页LOGO上传成功!')
showToast('登录页LOGO上传成功!', { variant: 'success' })
} else {
const error = await response.json()
alert(error.error || 'LOGO上传失败')
showToast(error.error || 'LOGO上传失败', { variant: 'error' })
}
} catch (error) {
console.error('Upload logo error:', error)
alert('上传失败,请稍后重试')
showToast('上传失败,请稍后重试', { variant: 'error' })
} finally {
setUploadingLogin(false)
}
@ -684,6 +689,11 @@ export default function SystemSettings() {
</div>
</div>
</main>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isSuperAdmin } from '@/lib/department-permission';
/**
* POST - 批量更新权限配置
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const body = await request.json();
const { department } = body || {};
const permissions: Array<{
materialType: string;
canView?: boolean;
canDownload?: boolean;
canShare?: boolean;
}> = body?.permissions || [];
if (!department || !Array.isArray(permissions) || permissions.length === 0) {
return NextResponse.json(
{ error: '部门名称和素材类型列表不能为空' },
{ status: 400 }
);
}
const materialTypes = permissions.map((p) => p.materialType).filter(Boolean);
// 检查是否有关联部门
const existing = await prisma.departmentMaterialPermission.findFirst({
where: {
department,
materialType: { in: materialTypes },
linkedToDepartment: { not: null },
},
});
if (existing) {
return NextResponse.json(
{ error: '该部门已关联到其他部门,请先取消关联' },
{ status: 400 }
);
}
// 批量更新权限配置
const updates = permissions.map((perm) =>
prisma.departmentMaterialPermission.upsert({
where: {
department_materialType: {
department,
materialType: perm.materialType,
},
},
update: {
canView: perm.canView ?? false,
canDownload: perm.canDownload ?? false,
canShare: perm.canShare ?? true,
linkedToDepartment: null,
},
create: {
department,
materialType: perm.materialType,
canView: perm.canView ?? false,
canDownload: perm.canDownload ?? false,
canShare: perm.canShare ?? true,
linkedToDepartment: null,
},
})
);
await Promise.all(updates);
return NextResponse.json({
message: '批量更新成功',
updatedCount: permissions.length,
});
} catch (error) {
console.error('[Department Permissions Batch] Error:', error);
return NextResponse.json({ error: '批量更新失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isSuperAdmin } from '@/lib/department-permission';
/**
* POST - 复制权限配置
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { sourceDepartment, targetDepartments } = await request.json();
if (!sourceDepartment || !targetDepartments || !Array.isArray(targetDepartments)) {
return NextResponse.json(
{ error: '源部门和目标部门不能为空' },
{ status: 400 }
);
}
// 获取源部门的所有权限配置
const sourcePermissions = await prisma.departmentMaterialPermission.findMany({
where: { department: sourceDepartment },
});
if (sourcePermissions.length === 0) {
return NextResponse.json(
{ error: '源部门没有权限配置' },
{ status: 400 }
);
}
// 为每个目标部门复制权限
for (const targetDept of targetDepartments) {
if (targetDept === sourceDepartment) {
continue; // 跳过自己
}
// 先删除目标部门的所有权限配置
await prisma.departmentMaterialPermission.deleteMany({
where: { department: targetDept },
});
// 复制源部门的权限配置
const newPermissions = sourcePermissions.map((perm) => ({
department: targetDept,
materialType: perm.materialType,
canView: perm.canView,
canDownload: perm.canDownload,
canShare: perm.canShare,
linkedToDepartment: null, // 复制后不关联
}));
await prisma.departmentMaterialPermission.createMany({
data: newPermissions,
});
}
return NextResponse.json({
message: '权限复制成功',
copiedCount: targetDepartments.length,
});
} catch (error) {
console.error('[Department Permissions Copy] Error:', error);
return NextResponse.json({ error: '复制权限失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getAllMaterialTypes, createDefaultPermissionsForDepartment, isSuperAdmin } from '@/lib/department-permission';
/**
* POST - 关联权限配置
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { targetDepartment, sourceDepartment } = await request.json();
if (!targetDepartment || !sourceDepartment) {
return NextResponse.json(
{ error: '目标部门和源部门不能为空' },
{ status: 400 }
);
}
if (targetDepartment === sourceDepartment) {
return NextResponse.json(
{ error: '不能关联到自己' },
{ status: 400 }
);
}
// 确保源部门有权限配置
await createDefaultPermissionsForDepartment(sourceDepartment);
// 确保目标部门有权限配置
await createDefaultPermissionsForDepartment(targetDepartment);
// 获取所有素材类型(从数据库动态获取)
const allMaterialTypes = await getAllMaterialTypes();
// 更新目标部门所有素材类型的关联
for (const materialType of allMaterialTypes) {
await prisma.departmentMaterialPermission.upsert({
where: {
department_materialType: {
department: targetDepartment,
materialType,
},
},
update: {
linkedToDepartment: sourceDepartment,
},
create: {
department: targetDepartment,
materialType,
canView: false,
canDownload: false,
canShare: true,
linkedToDepartment: sourceDepartment,
},
});
}
return NextResponse.json({
message: '权限关联成功',
targetDepartment,
sourceDepartment,
});
} catch (error) {
console.error('[Department Permissions Link] Error:', error);
return NextResponse.json({ error: '关联权限失败' }, { status: 500 });
}
}
/**
* DELETE - 取消关联权限配置
*/
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const department = searchParams.get('department');
if (!department) {
return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 });
}
// 清除该部门所有权限配置的关联
await prisma.departmentMaterialPermission.updateMany({
where: {
department,
linkedToDepartment: { not: null },
},
data: {
linkedToDepartment: null,
},
});
return NextResponse.json({
message: '已取消关联',
department,
});
} catch (error) {
console.error('[Department Permissions Unlink] Error:', error);
return NextResponse.json({ error: '取消关联失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import {
getAllMaterialTypes,
getDepartmentPermissions,
createDefaultPermissionsForDepartment,
isSuperAdmin,
} from '@/lib/department-permission';
/**
* GET - 获取所有部门的权限配置
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const searchKeyword = searchParams.get('search') || '';
// 获取所有部门
const departments = await prisma.department.findMany({
orderBy: { name: 'asc' },
});
// 过滤部门(如果有关键词)
const filteredDepartments = searchKeyword
? departments.filter((dept) => dept.name.includes(searchKeyword))
: departments;
// 获取所有部门的权限配置
const permissionsMap: Record<string, Record<string, any>> = {};
const linkedDepartments: Record<string, string> = {};
for (const dept of filteredDepartments) {
const permissions = await getDepartmentPermissions(dept.name);
permissionsMap[dept.name] = permissions;
// 检查是否有关联部门
const deptPermissions = await prisma.departmentMaterialPermission.findFirst({
where: {
department: dept.name,
linkedToDepartment: { not: null },
},
});
if (deptPermissions?.linkedToDepartment) {
linkedDepartments[dept.name] = deptPermissions.linkedToDepartment;
}
}
// 从数据库动态获取所有素材类型
const materialTypes = await getAllMaterialTypes();
return NextResponse.json({
departments: filteredDepartments.map((d) => d.name),
permissions: permissionsMap,
linkedDepartments,
materialTypes,
});
} catch (error) {
console.error('[Department Permissions] GET Error:', error);
return NextResponse.json({ error: '获取权限配置失败' }, { status: 500 });
}
}
/**
* POST - 更新权限配置
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const body = await request.json();
const { department, materialType, canView, canDownload, canShare } = body;
if (!department || !materialType) {
return NextResponse.json({ error: '部门名称和素材类型不能为空' }, { status: 400 });
}
// 检查是否有关联部门(如果有关联,不允许直接修改)
const existing = await prisma.departmentMaterialPermission.findUnique({
where: {
department_materialType: {
department,
materialType,
},
},
});
if (existing?.linkedToDepartment) {
return NextResponse.json(
{ error: '该部门已关联到其他部门,请先取消关联' },
{ status: 400 }
);
}
// 更新或创建权限配置
const permission = await prisma.departmentMaterialPermission.upsert({
where: {
department_materialType: {
department,
materialType,
},
},
update: {
canView: canView ?? false,
canDownload: canDownload ?? false,
canShare: canShare ?? true,
linkedToDepartment: null, // 更新时清除关联
},
create: {
department,
materialType,
canView: canView ?? false,
canDownload: canDownload ?? false,
canShare: canShare ?? true,
linkedToDepartment: null,
},
});
return NextResponse.json({ permission });
} catch (error) {
console.error('[Department Permissions] POST Error:', error);
return NextResponse.json({ error: '更新权限配置失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 批量删除素材(软删除或永久删除)
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const { ids, permanent } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 })
}
if (permanent) {
// 永久删除:从数据库中彻底删除
await prisma.material.deleteMany({
where: {
id: { in: ids }
}
})
} else {
// 软删除:设置deletedAt字段
await prisma.material.updateMany({
where: {
id: { in: ids }
},
data: {
deletedAt: new Date()
}
})
}
return NextResponse.json({
success: true,
message: permanent ? '批量永久删除成功' : '批量删除成功'
})
} catch (error) {
console.error('Batch delete materials error:', error)
return NextResponse.json({ error: '批量删除失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isSuperAdmin, canAddSuperAdmin, getSuperAdminCount } from '@/lib/department-permission';
/**
* GET - 获取所有超级管理员列表
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const superAdmins = await prisma.user.findMany({
where: { isSuperAdmin: true },
select: {
id: true,
email: true,
username: true,
realName: true,
department: true,
role: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
const count = await getSuperAdminCount();
const canAdd = await canAddSuperAdmin();
return NextResponse.json({
superAdmins,
count,
maxCount: 4,
canAdd,
});
} catch (error) {
console.error('[Super Admins] GET Error:', error);
return NextResponse.json({ error: '获取超级管理员列表失败' }, { status: 500 });
}
}
/**
* POST - 添加超级管理员
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { userId: targetUserId } = await request.json();
if (!targetUserId) {
return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 });
}
// 检查是否可以添加(最多4个)
if (!(await canAddSuperAdmin())) {
return NextResponse.json({ error: '超级管理员数量已达上限(最多4个)' }, { status: 400 });
}
// 检查目标用户是否存在
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
});
if (!targetUser) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
// 检查是否已经是超级管理员
if (targetUser.isSuperAdmin) {
return NextResponse.json({ error: '该用户已经是超级管理员' }, { status: 400 });
}
// 检查是否是管理员角色
if (targetUser.role !== 'ADMIN') {
return NextResponse.json({ error: '只有管理员才能被设置为超级管理员' }, { status: 400 });
}
// 设置为超级管理员
const updatedUser = await prisma.user.update({
where: { id: targetUserId },
data: { isSuperAdmin: true },
select: {
id: true,
email: true,
username: true,
realName: true,
department: true,
role: true,
isSuperAdmin: true,
createdAt: true,
},
});
return NextResponse.json({
message: '超级管理员添加成功',
user: updatedUser,
});
} catch (error) {
console.error('[Super Admins] POST Error:', error);
return NextResponse.json({ error: '添加超级管理员失败' }, { status: 500 });
}
}
/**
* DELETE - 移除超级管理员
*/
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!session || !(await isSuperAdmin(userId))) {
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const targetUserId = searchParams.get('userId');
if (!targetUserId) {
return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 });
}
// 不能移除自己
if (targetUserId === userId) {
return NextResponse.json({ error: '不能移除自己的超级管理员权限' }, { status: 400 });
}
// 检查目标用户是否存在
const targetUser = await prisma.user.findUnique({
where: { id: targetUserId },
});
if (!targetUser) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
// 检查是否是超级管理员
if (!targetUser.isSuperAdmin) {
return NextResponse.json({ error: '该用户不是超级管理员' }, { status: 400 });
}
// 检查移除后是否至少还有1个超级管理员
const count = await getSuperAdminCount();
if (count <= 1) {
return NextResponse.json({ error: '至少需要保留1个超级管理员' }, { status: 400 });
}
// 移除超级管理员权限
const updatedUser = await prisma.user.update({
where: { id: targetUserId },
data: { isSuperAdmin: false },
select: {
id: true,
email: true,
username: true,
realName: true,
department: true,
role: true,
isSuperAdmin: true,
createdAt: true,
},
});
return NextResponse.json({
message: '超级管理员权限已移除',
user: updatedUser,
});
} catch (error) {
console.error('[Super Admins] DELETE Error:', error);
return NextResponse.json({ error: '移除超级管理员失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,241 @@
/**
* 系统状态监控 API
*
* GET: 获取系统整体状态和统计数据
*
* 参考:flnxi_sucai/app/api/admin/system-status/route.ts
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
// 检查管理员权限
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限访问系统状态' },
{ status: 403 }
)
}
// 计算时间范围(30天前和7天前)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// 并行查询基础数据
let newUsersThisMonth: any, newMaterialsThisMonth: any, siteSettings: any, recentMaterials: any, totalUsers: number, totalMaterials: number, activeUsers: number, departmentStats: any[], roleStats: any[]
try {
[newUsersThisMonth, newMaterialsThisMonth, siteSettings, recentMaterials, totalUsers, totalMaterials, activeUsers, departmentStats, roleStats] = await Promise.all([
prisma.user.aggregate({
_count: { id: true },
where: { createdAt: { gte: thirtyDaysAgo } },
}),
prisma.material.aggregate({
_count: { id: true },
where: { createdAt: { gte: thirtyDaysAgo }, deletedAt: null } as any,
}),
prisma.systemSettings.findFirst({
select: { siteName: true },
}),
prisma.material.findMany({
take: 10,
orderBy: { createdAt: 'desc' },
where: { deletedAt: null } as any,
select: {
id: true,
productModel: true,
createdAt: true,
user: {
select: {
username: true,
email: true,
},
},
},
}),
prisma.user.count(),
prisma.material.count({ where: { deletedAt: null } as any }),
prisma.user.count({ where: { updatedAt: { gte: sevenDaysAgo } } }),
prisma.user.groupBy({
by: ['department'],
_count: { id: true },
}),
prisma.user.groupBy({
by: ['role'],
_count: { id: true },
}),
])
} catch (error: any) {
console.error('[SystemStatus] Database query error:', error)
// 降级处理
newUsersThisMonth = { _count: { id: 0 } }
newMaterialsThisMonth = { _count: { id: 0 } }
siteSettings = null
recentMaterials = []
totalUsers = 0
totalMaterials = 0
activeUsers = 0
departmentStats = []
roleStats = []
}
// 获取素材统计(兼容旧数据库)
let materialStats: any = {}
try {
// 尝试按素材类型分组统计
const materialTypeStats = await prisma.material.groupBy({
by: ['materialType'],
_count: {
id: true,
},
where: {
deletedAt: null, // 只统计未删除的素材
} as any,
})
const totalMaterials = materialTypeStats.reduce((sum, stat) => sum + stat._count.id, 0)
materialStats = {
total: totalMaterials,
byType: materialTypeStats.reduce((acc, stat) => {
acc[stat.materialType] = stat._count.id
return acc
}, {} as Record<string, number>),
}
// 获取已删除素材数量
try {
const deletedCount = await prisma.material.count({
where: {
deletedAt: { not: null },
} as any,
})
materialStats.deleted = deletedCount
} catch {
// deletedAt 字段不存在,跳过
materialStats.deleted = 0
}
} catch (error: any) {
// 如果 deletedAt 字段不存在,降级统计
if (error?.meta?.column?.includes('deletedAt')) {
const materialTypeStats = await prisma.material.groupBy({
by: ['materialType'],
_count: {
id: true,
},
})
const totalMaterials = materialTypeStats.reduce((sum, stat) => sum + stat._count.id, 0)
materialStats = {
total: totalMaterials,
byType: materialTypeStats.reduce((acc, stat) => {
acc[stat.materialType] = stat._count.id
return acc
}, {} as Record<string, number>),
deleted: 0, // 旧数据库没有软删除
}
} else {
throw error
}
}
// 获取下载统计(最近7天)
let downloadStats: any = {}
try {
const recentDownloads = await (prisma as any).materialDownloadLog.count({
where: {
downloadedAt: {
gte: sevenDaysAgo,
},
},
})
const totalDownloads = await (prisma as any).materialDownloadLog.count()
downloadStats = {
total: totalDownloads,
recent7Days: recentDownloads,
}
} catch (error: any) {
// 如果下载日志表不存在
if (error?.meta?.table?.includes('material_download_logs')) {
downloadStats = {
total: 0,
recent7Days: 0,
}
} else {
throw error
}
}
// 获取收藏统计
let favoriteStats: any = {}
try {
const totalFavorites = await (prisma as any).materialFavorite.count()
favoriteStats = {
total: totalFavorites,
}
} catch (error: any) {
// 如果收藏表不存在
if (error?.meta?.table?.includes('material_favorites')) {
favoriteStats = {
total: 0,
}
} else {
throw error
}
}
// 获取存储统计(素材文件大小)
const storageStats = await prisma.material.aggregate({
_sum: {
zipFileSize: true,
},
})
const totalStorageBytes = Number(storageStats._sum.zipFileSize || 0)
const totalStorageGB = (totalStorageBytes / (1024 * 1024 * 1024)).toFixed(2)
// 从roleStats计算管理员和普通用户数量
const adminUsers = roleStats.find((r: any) => r.role === 'ADMIN')?._count?.id || 0
const regularUsers = roleStats.find((r: any) => r.role === 'USER')?._count?.id || 0
// 系统信息
const systemInfo = {
nodeVersion: process.version,
platform: process.platform,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
timestamp: new Date().toISOString(),
}
return NextResponse.json({
users: {
total: totalUsers,
admin: adminUsers,
regular: regularUsers,
},
materials: materialStats,
downloads: downloadStats,
favorites: favoriteStats,
storage: {
totalBytes: totalStorageBytes,
totalGB: parseFloat(totalStorageGB),
},
system: systemInfo,
})
} catch (error) {
console.error('[Admin] System status error:', error)
return NextResponse.json(
{ error: '获取系统状态失败' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,93 @@
/**
* 回收站 API - 获取已删除的素材
*
* GET: 获取所有已删除的素材(仅管理员)
*/
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'
export async function GET(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 page = parseInt(searchParams.get('page') || '1')
const pageSize = parseInt(searchParams.get('pageSize') || '20')
const skip = (page - 1) * pageSize
// 获取已删除的素材
const [deletedMaterials, total] = await Promise.all([
prisma.material.findMany({
where: {
deletedAt: { not: null }, // 只返回已删除的素材
},
select: {
id: true,
userId: true,
productModel: true,
previewUrl: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
createdAt: true,
deletedAt: true,
user: {
select: {
username: true,
},
},
},
orderBy: { deletedAt: 'desc' },
skip,
take: pageSize,
} as any),
prisma.material.count({
where: {
deletedAt: { not: null },
},
} as any),
])
// 应用 URL 重写
const formatted = deletedMaterials.map((material: any) =>
rewriteMaterialStorageUrlsForClient(material)
)
return NextResponse.json({
materials: formatted,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
})
} catch (error: any) {
// 兼容旧数据库:如果 deletedAt 字段不存在
if (error?.meta?.column?.includes('deletedAt')) {
return NextResponse.json(
{ error: '回收站功能需要先运行数据库迁移' },
{ status: 400 }
)
}
console.error('[Admin Trash] GET error:', error)
return NextResponse.json(
{ error: '获取回收站列表失败' },
{ status: 500 }
)
}
}

View File

@ -1,20 +1,16 @@
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';
// Get all users (admin only)
// GET /api/admin/users - 获取用户列表
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const users = await prisma.user.findMany({
select: {
id: true,
@ -24,69 +20,57 @@ export async function GET() {
isActive: true,
canDownload: true,
department: true,
managedDepartment: true, // 管理员负责的部门
createdAt: true,
_count: {
select: {
materials: true,
materials: {
where: {
deletedAt: null,
} as any,
},
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json(users)
});
return NextResponse.json(users);
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json(
{ error: '获取用户列表失败' },
{ status: 500 }
)
console.error('Error fetching users:', error);
return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 });
}
}
// Delete user (admin only)
export async function DELETE(req: NextRequest) {
// DELETE /api/admin/users - 删除用户
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
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');
if (!id) {
return NextResponse.json(
{ error: '缺少用户ID' },
{ status: 400 }
)
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
}
// Don't allow deleting self
// 不能删除自己
if (id === (session.user as any).id) {
return NextResponse.json(
{ error: '不能删除自己' },
{ status: 400 }
)
return NextResponse.json({ error: '不能删除自己' }, { status: 400 });
}
await prisma.user.delete({
where: { id },
})
return NextResponse.json({ message: '删除成功' })
});
return NextResponse.json({ message: '删除成功' });
} catch (error) {
console.error('Error deleting user:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
console.error('Error deleting user:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
}
}

View File

@ -1,86 +1,111 @@
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';
export async function POST(req: NextRequest) {
/**
* 设置部门管理员
* POST /api/admin/users/set-department-manager
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
// 验证管理员权限
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
);
}
const { userId, departmentName } = await req.json()
const { userId, departmentName } = await request.json();
if (!userId || !departmentName) {
return NextResponse.json({ error: '用户ID和部门名称不能为空' }, { status: 400 })
return NextResponse.json(
{ error: '用户ID和部门名称不能为空' },
{ status: 400 }
);
}
// 检查该部门是否已经有管理员
// 检查该部门是否已有管理员
const existingManager = await prisma.user.findFirst({
where: {
managedDepartment: departmentName,
NOT: { id: userId }
}
})
});
if (existingManager) {
return NextResponse.json({
error: `部门"${departmentName}"已有管理员:${existingManager.username}`
}, { status: 400 })
return NextResponse.json(
{ error: `部门"${departmentName}"已有管理员:${existingManager.username}` },
{ status: 400 }
);
}
// 更新用户为部门管理员
// 设置部门管理员(同时提升为管理员角色)
const user = await prisma.user.update({
where: { id: userId },
data: {
data: {
managedDepartment: departmentName,
role: 'ADMIN' // 设置为管理员角色
role: 'ADMIN'
}
})
});
return NextResponse.json({
return NextResponse.json({
message: '部门管理员设置成功',
user
})
user
});
} catch (error) {
console.error('Set department manager error:', error)
return NextResponse.json({ error: '设置失败' }, { status: 500 })
console.error('Set department manager error:', error);
return NextResponse.json(
{ error: '设置失败' },
{ status: 500 }
);
}
}
// 取消部门管理员
export async function DELETE(req: NextRequest) {
/**
* 取消部门管理员
* DELETE /api/admin/users/set-department-manager?userId=xxx
*/
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
// 验证管理员权限
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
);
}
const { searchParams } = new URL(req.url)
const userId = searchParams.get('userId')
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 })
return NextResponse.json(
{ error: '用户ID不能为空' },
{ status: 400 }
);
}
// 取消部门管理员
const user = await prisma.user.update({
where: { id: userId },
data: {
data: {
managedDepartment: null
}
})
});
return NextResponse.json({
return NextResponse.json({
message: '已取消部门管理员',
user
})
user
});
} catch (error) {
console.error('Remove department manager error:', error)
return NextResponse.json({ error: '取消失败' }, { status: 500 })
console.error('Remove department manager error:', error);
return NextResponse.json(
{ error: '取消失败' },
{ status: 500 }
);
}
}

View File

@ -1,62 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 设置用户角色API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
// POST - 设置用户角色(仅管理员)
export async function POST(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 { userId, role } = await req.json()
const { userId, role } = await request.json();
if (!userId || !role) {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 }
)
);
}
// 验证角色值
if (role !== 'USER' && role !== 'ADMIN') {
return NextResponse.json(
{ error: '角色值无效' },
{ status: 400 }
)
);
}
// 检查目标用户是否存在
const targetUser = await prisma.user.findUnique({
// 检查用户是否存在
const user = await prisma.user.findUnique({
where: { id: userId },
})
});
if (!targetUser) {
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
)
);
}
// 不能修改自己的角色
if (targetUser.email === session.user?.email) {
if (user.email === session.user?.email) {
return NextResponse.json(
{ error: '不能修改自己的角色' },
{ status: 400 }
)
);
}
// 更新用户角色
// 更新角色
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { role },
})
});
return NextResponse.json({
success: true,
@ -66,13 +65,12 @@ export async function POST(req: NextRequest) {
username: updatedUser.username,
role: updatedUser.role,
},
})
});
} catch (error) {
console.error('Error updating user role:', error)
console.error('Error updating user role:', error);
return NextResponse.json(
{ error: '设置角色失败' },
{ status: 500 }
)
);
}
}

View File

@ -1,52 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 切换用户激活状态API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// 激活/禁用用户
export async function POST(req: NextRequest) {
// POST - 切换用户激活状态(仅管理员)
export async function POST(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 { userId, isActive } = await req.json()
const { userId, isActive } = await request.json();
if (!userId || typeof isActive !== 'boolean') {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 }
)
);
}
// 不能禁用自己
if (userId === (session.user as any).id) {
if (userId === session.user.id) {
return NextResponse.json(
{ error: '不能禁用自己' },
{ status: 400 }
)
);
}
// 更新用户激活状态
// 更新激活状态
await prisma.user.update({
where: { id: userId },
data: { isActive },
})
});
return NextResponse.json({
message: isActive ? '用户已激活' : '用户已禁用',
})
});
} catch (error) {
console.error('Toggle active error:', error)
console.error('Toggle active error:', error);
return NextResponse.json(
{ error: '操作失败' },
{ status: 500 }
)
);
}
}

View File

@ -1,74 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 切换用户下载权限API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// Toggle user download permission (admin only)
export async function POST(req: NextRequest) {
// POST - 切换用户下载权限(仅管理员)
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
// Check if user is admin
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 { userId } = await req.json()
const { userId } = await request.json();
if (!userId) {
return NextResponse.json(
{ error: '缺少用户ID' },
{ status: 400 }
)
);
}
// Prevent admin from disabling their own download permission
if (userId === (session.user as any).id) {
// 不能修改自己的下载权限
if (userId === session.user.id) {
return NextResponse.json(
{ error: '不能修改自己的下载权限' },
{ status: 400 }
)
);
}
// Get current user
// 检查用户是否存在
const user = await prisma.user.findUnique({
where: { id: userId },
})
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
)
);
}
// Prevent disabling download for other admins
// 不能修改管理员的下载权限
if (user.role === 'ADMIN') {
return NextResponse.json(
{ error: '不能修改管理员的下载权限' },
{ status: 400 }
)
);
}
// Toggle download permission
// 切换下载权限
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { canDownload: !user.canDownload },
})
});
return NextResponse.json({
message: updatedUser.canDownload ? '已开启下载权限' : '已关闭下载权限',
canDownload: updatedUser.canDownload,
})
});
} catch (error) {
console.error('Error toggling download permission:', error)
console.error('Error toggling download permission:', error);
return NextResponse.json(
{ error: '操作失败' },
{ status: 500 }
)
);
}
}

View File

@ -1,49 +1,54 @@
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';
// 更新用户部门
export async function POST(req: NextRequest) {
/**
* 更新用户部门
* POST /api/admin/users/update-department
*/
export async function POST(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 { userId, department } = await req.json()
const { userId, department } = await request.json();
console.log('[Update Department] Request:', { userId, department })
console.log('[Update Department] Request:', { userId, department });
if (!userId) {
return NextResponse.json(
{ error: '缺少用户ID' },
{ status: 400 }
)
);
}
// 更新用户部门
const user = await prisma.user.update({
where: { id: userId },
data: { department: department || '' },
})
data: {
department: department || ''
}
});
console.log('[Update Department] Success:', {
userId: user.id,
console.log('[Update Department] Success:', {
userId: user.id,
username: user.username,
department: user.department
})
department: user.department
});
return NextResponse.json(user)
return NextResponse.json(user);
} catch (error) {
console.error('[Update Department] Error:', error)
console.error('[Update Department] Error:', error);
return NextResponse.json(
{ error: '更新部门失败' },
{ status: 500 }
)
);
}
}

View File

@ -0,0 +1,188 @@
/**
* 视频转码统计 API
*
* GET: 获取视频转码队列状况、失败任务等统计数据
*
* 参考:flnxi_sucai/app/api/admin/video-transcode-stats/route.ts
* 适配当前系统使用 materialType 字段
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
// 检查管理员权限
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
}
// 识别视频素材类型
const videoMaterialTypes = ['主图视频', '安装视频', '种草视频', '视频']
// 获取视频素材统计(兼容旧数据库)
let videoStats: any = {}
try {
// 查询所有视频素材
const videoMaterials = await prisma.material.findMany({
where: {
AND: [
{
materialType: {
in: videoMaterialTypes,
},
},
{
deletedAt: null, // 只统计未删除的素材
} as any,
],
} as any,
select: {
id: true,
materialType: true,
videoProcessingStatus: true,
transcodeLastError: true,
transcodeAttempts: true,
createdAt: true,
} as any, // 使用 as any 兼容旧数据库
})
// 按转码状态分组统计
const statusCounts = {
pending: 0, // 待转码
processing: 0, // 转码中
completed: 0, // 转码完成
failed: 0, // 转码失败
unknown: 0, // 状态未知
}
const failedTasks: any[] = []
const recentTasks: any[] = []
videoMaterials.forEach((material: any) => {
const status = material.videoProcessingStatus || 'unknown'
switch (status) {
case 'pending':
case 'queued':
statusCounts.pending++
break
case 'processing':
case 'transcoding':
statusCounts.processing++
break
case 'completed':
case 'success':
statusCounts.completed++
break
case 'failed':
case 'error':
statusCounts.failed++
failedTasks.push({
id: material.id,
materialType: material.materialType,
error: material.transcodeLastError,
attempts: material.transcodeAttempts || 0,
createdAt: material.createdAt,
})
break
default:
statusCounts.unknown++
}
// 收集最近的任务(最近24小时)
const oneDayAgo = new Date()
oneDayAgo.setDate(oneDayAgo.getDate() - 1)
if (material.createdAt && new Date(material.createdAt) > oneDayAgo) {
recentTasks.push({
id: material.id,
materialType: material.materialType,
status: material.videoProcessingStatus || 'unknown',
createdAt: material.createdAt,
})
}
})
videoStats = {
total: videoMaterials.length,
byStatus: statusCounts,
byType: videoMaterialTypes.reduce((acc, type) => {
acc[type] = videoMaterials.filter((m: any) => m.materialType === type).length
return acc
}, {} as Record<string, number>),
failedTasks: failedTasks.slice(0, 10), // 最多返回10个失败任务
recentTasks: recentTasks.slice(0, 20), // 最多返回20个最近任务
}
} catch (error: any) {
// 兼容旧数据库:如果视频相关字段不存在
if (error?.meta?.column?.includes('videoProcessingStatus') ||
error?.meta?.column?.includes('deletedAt')) {
console.warn('[Admin] Video fields not found, using fallback query')
// 降级查询:只统计视频素材数量
const videoMaterials = await prisma.material.findMany({
where: {
materialType: {
in: videoMaterialTypes,
},
},
select: {
id: true,
materialType: true,
createdAt: true,
},
})
videoStats = {
total: videoMaterials.length,
byStatus: {
pending: 0,
processing: 0,
completed: videoMaterials.length, // 假设都已完成
failed: 0,
unknown: 0,
},
byType: videoMaterialTypes.reduce((acc, type) => {
acc[type] = videoMaterials.filter(m => m.materialType === type).length
return acc
}, {} as Record<string, number>),
failedTasks: [],
recentTasks: videoMaterials.slice(0, 20).map(m => ({
id: m.id,
materialType: m.materialType,
status: 'completed',
createdAt: m.createdAt,
})),
}
} else {
throw error
}
}
// 系统资源使用情况
const systemResources = {
memory: process.memoryUsage(),
uptime: process.uptime(),
timestamp: new Date().toISOString(),
}
return NextResponse.json({
video: videoStats,
system: systemResources,
})
} catch (error) {
console.error('[Admin] Video transcode stats error:', error)
return NextResponse.json(
{ error: '获取转码统计失败' },
{ status: 500 }
)
}
}

View File

@ -1,64 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
// AI聊天API - 使用Gemini API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function POST(req: NextRequest) {
// POST - 发送消息到AI
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '未授权' },
{ status: 401 }
)
);
}
const { messages, model } = await req.json()
const { messages, model } = await request.json();
const apiKey = process.env.GEMINI_API_KEY
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'
const modelName = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'
const apiKey = process.env.GEMINI_API_KEY;
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
const modelName = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
if (!apiKey) {
return NextResponse.json(
{ error: 'Gemini API未配置' },
{ status: 500 }
)
);
}
// 构建Gemini API请求
// 转换消息格式为Gemini格式
const geminiMessages = messages.map((msg: any) => {
// 文本消息
if (typeof msg.content === 'string') {
return {
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }]
}
} else if (Array.isArray(msg.content)) {
parts: [{ text: msg.content }],
};
}
// 多模态消息(文本+图片)
if (Array.isArray(msg.content)) {
const parts = msg.content.map((part: any) => {
if (part.type === 'text') {
return { text: part.text }
} else if (part.type === 'image_url') {
// 提取base64图片数据
const base64Match = part.image_url.url.match(/data:image\/([^;]+);base64,(.+)/)
if (base64Match) {
return { text: part.text };
}
if (part.type === 'image_url') {
// 解析base64图片
const match = part.image_url.url.match(/data:image\/([^;]+);base64,(.+)/);
if (match) {
return {
inlineData: {
mimeType: `image/${base64Match[1]}`,
data: base64Match[2]
}
}
mimeType: `image/${match[1]}`,
data: match[2],
},
};
}
}
return null
}).filter(Boolean)
return null;
}).filter(Boolean);
return {
role: msg.role === 'assistant' ? 'model' : 'user',
parts
}
parts,
};
}
return null
}).filter(Boolean)
return null;
}).filter(Boolean);
// 调用Gemini API
const response = await fetch(
@ -78,39 +85,37 @@ export async function POST(req: NextRequest) {
},
}),
}
)
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.error('Gemini API error:', errorData)
const errorData = await response.json().catch(() => ({}));
console.error('Gemini API error:', errorData);
return NextResponse.json(
{ error: errorData.error?.message || '调用Gemini API失败' },
{ status: response.status }
)
);
}
const data = await response.json()
return NextResponse.json(data)
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('AI chat error:', error)
console.error('AI chat error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '处理失败' },
{ status: 500 }
)
);
}
}
// GET endpoint to check API configuration
// GET - 获取AI配置状态
export async function GET() {
const apiKey = process.env.GEMINI_API_KEY
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'
const apiKey = process.env.GEMINI_API_KEY;
const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
return NextResponse.json({
configured: !!apiKey,
apiBaseUrl,
model
})
model,
});
}

View File

@ -1,7 +1,7 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
// NextAuth 路由处理器
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -1,37 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(req: NextRequest) {
// POST /api/auth/register - 用户注册
export async function POST(request: NextRequest) {
try {
const { email, username, department, password } = await req.json()
// Validation
const { email, username, department, password } = await request.json();
if (!email || !username || !password) {
return NextResponse.json(
{ error: '所有字段都是必填的' },
{ status: 400 }
)
return NextResponse.json({ error: '所有字段都是必填的' }, { status: 400 });
}
// Check if user exists
// 检查用户是否已存在
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
})
});
if (existingUser) {
return NextResponse.json(
{ error: '邮箱或用户名已存在' },
{ status: 400 }
)
return NextResponse.json({ error: '邮箱或用户名已存在' }, { status: 400 });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10)
// Create user
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = await prisma.user.create({
data: {
email,
@ -47,18 +41,14 @@ export async function POST(req: NextRequest) {
role: true,
createdAt: true,
},
})
});
return NextResponse.json(
{ message: '注册成功', user },
{ status: 201 }
)
);
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: '注册失败,请稍后再试' },
{ status: 500 }
)
console.error('Registration error:', error);
return NextResponse.json({ error: '注册失败,请稍后再试' }, { status: 500 });
}
}

View File

@ -1,196 +1,178 @@
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';
// 获取所有使用店铺
// GET - 获取所有使用店铺分类
export async function GET() {
try {
const categories = await prisma.category.findMany({
orderBy: {
name: 'asc',
},
})
orderBy: { name: 'asc' },
});
// 获取每个店铺下的素材数量
// 统计每个分类的素材数量(只统计未删除的素材)
const categoriesWithCount = await Promise.all(
categories.map(async (category) => {
const count = await prisma.material.count({
where: {
categoryName: category.name,
},
})
let count: number;
try {
count = await prisma.material.count({
where: {
categoryName: category.name,
deletedAt: null,
} as any,
});
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
count = await prisma.material.count({
where: { categoryName: category.name },
});
} else {
throw e;
}
}
return {
...category,
count,
}
};
})
)
);
return NextResponse.json(categoriesWithCount)
return NextResponse.json(categoriesWithCount);
} catch (error) {
console.error('Error fetching categories:', error)
console.error('Error fetching categories:', error);
return NextResponse.json(
{ error: '获取店铺列表失败' },
{ status: 500 }
)
);
}
}
// 添加新的使用店铺
export async function POST(req: NextRequest) {
// POST - 创建新的使用店铺分类
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const { name } = await req.json()
const { name } = await request.json();
if (!name || !name.trim()) {
return NextResponse.json(
{ error: '店铺名称不能为空' },
{ status: 400 }
)
);
}
// 检查店铺是否已存在
// 检查是否已存在
const existing = await prisma.category.findUnique({
where: { name: name.trim() },
})
});
if (existing) {
return NextResponse.json(
{ error: '该店铺已存在' },
{ status: 400 }
)
);
}
// 创建新店铺
const category = await prisma.category.create({
data: {
name: name.trim(),
},
})
data: { name: name.trim() },
});
return NextResponse.json(category, { status: 201 })
return NextResponse.json(category, { status: 201 });
} catch (error) {
console.error('Error creating category:', error)
console.error('Error creating category:', error);
return NextResponse.json(
{ error: '创建店铺失败' },
{ status: 500 }
)
);
}
}
// 更新店铺名称
export async function PATCH(req: NextRequest) {
// PATCH - 更新使用店铺分类
export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '权限不足' },
{ status: 403 }
)
if (!session || session.user?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const { id, name } = await req.json()
const { id, name } = await request.json();
if (!id || !name || !name.trim()) {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 }
)
return NextResponse.json({ error: '参数错误' }, { status: 400 });
}
// 检查新名称是否已存在(排除当前店铺)
// 检查新名称是否已被其他分类使用
const existing = await prisma.category.findFirst({
where: {
name: name.trim(),
NOT: { id },
},
})
});
if (existing) {
return NextResponse.json(
{ error: '该店铺名称已存在' },
{ status: 400 }
)
);
}
// 获取旧名称
const oldCategory = await prisma.category.findUnique({
where: { id },
})
});
if (!oldCategory) {
return NextResponse.json(
{ error: '分类不存在' },
{ status: 404 }
)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
}
// 更新店铺名称
const category = await prisma.category.update({
// 更新分类
const updated = await prisma.category.update({
where: { id },
data: { name: name.trim() },
})
});
// 同步更新所有使用该分类的素材
// 更新所有使用该分类的素材
await prisma.material.updateMany({
where: { categoryName: oldCategory.name },
data: { categoryName: name.trim() },
})
});
return NextResponse.json(category)
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating category:', error)
return NextResponse.json(
{ error: '更新失败' },
{ status: 500 }
)
console.error('Error updating category:', error);
return NextResponse.json({ error: '更新失败' }, { status: 500 });
}
}
// 删除店铺
export async function DELETE(req: NextRequest) {
// DELETE - 删除使用店铺分类
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '权限不足' },
{ status: 403 }
)
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');
if (!id) {
return NextResponse.json(
{ error: '缺少ID参数' },
{ status: 400 }
)
return NextResponse.json({ error: '缺少ID参数' }, { status: 400 });
}
// 删除店铺
await prisma.category.delete({
where: { id },
})
});
return NextResponse.json({ success: true })
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting category:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
console.error('Error deleting category:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
}
}

View File

@ -1,55 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 弹幕API - 支持SSE实时推送和普通查询
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET - 获取弹幕列表(支持SSE)
// GET - 获取弹幕列表(支持SSE实时推送)
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const isSSE = searchParams.get('sse') === 'true'
const { searchParams } = new URL(request.url);
const isSSE = searchParams.get('sse') === 'true';
// SSE模式 - 实时推送弹幕
if (isSSE) {
// Server-Sent Events 实时推送
const encoder = new TextEncoder()
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 发送初始弹幕
const initialDanmakus = await prisma.danmaku.findMany({
// 首次发送最近50条弹幕
const recentDanmaku = await prisma.danmaku.findMany({
orderBy: { createdAt: 'desc' },
take: 50
})
take: 50,
});
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(initialDanmakus)}\n\n`)
)
encoder.encode(`data: ${JSON.stringify(recentDanmaku)}\n\n`)
);
// 轮询新弹幕
// 每秒检查新弹幕
const interval = setInterval(async () => {
try {
const latestDanmaku = await prisma.danmaku.findMany({
const newDanmaku = await prisma.danmaku.findMany({
orderBy: { createdAt: 'desc' },
take: 1
})
if (latestDanmaku.length > 0) {
take: 1,
});
if (newDanmaku.length > 0) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(latestDanmaku[0])}\n\n`)
)
encoder.encode(`data: ${JSON.stringify(newDanmaku[0])}\n\n`)
);
}
} catch (error) {
console.error('SSE error:', error)
console.error('SSE error:', error);
}
}, 1000)
}, 1000);
// 清理
// 客户端断开连接时清理
request.signal.addEventListener('abort', () => {
clearInterval(interval)
controller.close()
})
}
})
clearInterval(interval);
controller.close();
});
},
});
return new NextResponse(stream, {
headers: {
@ -57,77 +58,77 @@ export async function GET(request: NextRequest) {
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
});
}
// 普通GET请求
const limit = parseInt(searchParams.get('limit') || '50')
const danmakus = await prisma.danmaku.findMany({
// 普通模式 - 返回弹幕列表
const limit = parseInt(searchParams.get('limit') || '50');
const danmaku = await prisma.danmaku.findMany({
orderBy: { createdAt: 'desc' },
take: limit
})
take: limit,
});
return NextResponse.json(danmakus)
return NextResponse.json(danmaku);
} catch (error) {
console.error('Get danmaku error:', error)
console.error('Get danmaku error:', error);
return NextResponse.json(
{ error: '获取弹幕失败' },
{ status: 500 }
)
);
}
}
// POST - 发送弹幕
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
);
}
const { content } = await request.json()
const { content } = await request.json();
// 验证弹幕内容
if (!content || content.trim().length === 0) {
return NextResponse.json(
{ error: '弹幕内容不能为空' },
{ status: 400 }
)
);
}
if (content.length > 100) {
return NextResponse.json(
{ error: '弹幕内容不能超过100字' },
{ status: 400 }
)
);
}
// 生成随机匿名用户名
const randomNames = [
'神秘访客', '路过的设计师', '热心网友', '匿名用户',
// 随机匿名昵称
const anonymousNames = [
'神秘访客', '路过的设计师', '热心网友', '匿名用户',
'悄悄路过', '神秘嘉宾', '隐身大佬', '低调看客',
'默默围观', '潜水员', '匿名网友', '神秘人士'
]
const anonymousName = randomNames[Math.floor(Math.random() * randomNames.length)]
];
const randomName = anonymousNames[Math.floor(Math.random() * anonymousNames.length)];
// 创建弹幕
const danmaku = await prisma.danmaku.create({
data: {
content: content.trim(),
username: anonymousName, // 使用匿名名称
userId: (session.user as any).id
}
})
username: randomName,
userId: session.user.id,
},
});
return NextResponse.json(danmaku)
return NextResponse.json(danmaku);
} catch (error) {
console.error('Send danmaku error:', error)
console.error('Send danmaku error:', error);
return NextResponse.json(
{ error: '发送弹幕失败' },
{ status: 500 }
)
);
}
}

View File

@ -1,107 +1,87 @@
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 { createDefaultPermissionsForDepartment } from '@/lib/department-permission';
// Get all departments
// GET /api/departments - 获取部门列表
export async function GET() {
try {
const departments = await prisma.department.findMany({
orderBy: {
createdAt: 'asc',
},
})
return NextResponse.json(departments)
orderBy: { createdAt: 'asc' },
});
return NextResponse.json(departments);
} catch (error) {
console.error('Error fetching departments:', error)
return NextResponse.json(
{ error: '获取部门列表失败' },
{ status: 500 }
)
console.error('Error fetching departments:', error);
return NextResponse.json({ error: '获取部门列表失败' }, { status: 500 });
}
}
// Create a new department (admin only)
export async function POST(req: NextRequest) {
// POST /api/departments - 创建部门
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { name } = await req.json()
const { name } = await request.json();
if (!name || name.trim() === '') {
return NextResponse.json(
{ error: '部门名称不能为空' },
{ status: 400 }
)
return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 });
}
// Check if department already exists
// 检查部门是否已存在
const existing = await prisma.department.findUnique({
where: { name },
})
});
if (existing) {
return NextResponse.json(
{ error: '部门已存在' },
{ status: 400 }
)
return NextResponse.json({ error: '部门已存在' }, { status: 400 });
}
const department = await prisma.department.create({
data: { name },
})
return NextResponse.json(department, { status: 201 })
});
// 自动创建默认权限配置
try {
await createDefaultPermissionsForDepartment(name);
} catch (error) {
console.error('Error creating default permissions for department:', error);
// 不阻止部门创建,只记录错误
}
return NextResponse.json(department, { status: 201 });
} catch (error) {
console.error('Error creating department:', error)
return NextResponse.json(
{ error: '创建部门失败' },
{ status: 500 }
)
console.error('Error creating department:', error);
return NextResponse.json({ error: '创建部门失败' }, { status: 500 });
}
}
// Delete a department (admin only)
export async function DELETE(req: NextRequest) {
// DELETE /api/departments - 删除部门
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
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');
if (!id) {
return NextResponse.json(
{ error: '缺少部门ID' },
{ status: 400 }
)
return NextResponse.json({ error: '缺少部门ID' }, { status: 400 });
}
await prisma.department.delete({
where: { id },
})
return NextResponse.json({ message: '删除成功' })
});
return NextResponse.json({ message: '删除成功' });
} catch (error) {
console.error('Error deleting department:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
console.error('Error deleting department:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
}
}

View File

@ -1,14 +1,15 @@
import { NextResponse } from 'next/server'
// 健康检查API
import { NextResponse } from 'next/server';
// Health check endpoint - 用于网络检测
// GET - 健康检查
export async function GET() {
return NextResponse.json({
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString()
})
timestamp: new Date().toISOString(),
});
}
// HEAD - 健康检查(轻量级)
export async function HEAD() {
return new NextResponse(null, { status: 200 })
return new NextResponse(null, { status: 200 });
}

View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 重试转码API
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
userId: true,
videoProcessingStatus: true,
videoSourceUrl: true
}
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
// 检查权限:只有素材上传者或管理员可以重试转码
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限重试转码' }, { status: 403 })
}
// 检查是否有视频源文件
if (!material.videoSourceUrl) {
return NextResponse.json({ error: '该素材没有视频源文件' }, { status: 400 })
}
// 更新转码状态为processing,重置错误信息
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'processing',
transcodeLastError: null,
transcodeAttempts: 0
}
})
// TODO: 这里应该触发实际的转码任务
// 由于转码服务是后台运行的,这里只是更新状态
// 实际的转码任务会由后台服务自动检测并处理
return NextResponse.json({
success: true,
message: '已重新提交转码任务'
})
} catch (error) {
console.error('重试转码失败:', error)
return NextResponse.json({ error: '重试转码失败' }, { status: 500 })
}
}

View File

@ -1,7 +1,35 @@
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 {
canUserViewMaterial,
isShareLinkAccess,
} from '@/lib/department-permission';
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation';
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
if (!displayImages) return [];
if (Array.isArray(displayImages)) {
return displayImages.filter((img: any) => 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: any) => typeof img === 'string' && img.trim().length > 0);
}
} catch (e) {
console.warn('[materials/[id]] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
@ -9,52 +37,166 @@ export async function GET(
{ params }: { params: { id: string } }
) {
try {
const material = await prisma.material.findUnique({
where: { id: params.id },
include: {
user: {
let material;
try {
material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
select: {
id: true,
userId: true,
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
user: {
select: {
id: true,
username: true,
},
},
} as any,
});
} catch (error: any) {
// 兼容旧版本数据库(没有 deletedAt 字段)
if (error?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
material = await prisma.material.findFirst({
where: {
id: params.id,
},
select: {
id: true,
username: true,
userId: true,
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
previewUrl: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
user: {
select: {
id: true,
username: true,
},
},
} as any,
});
} else {
throw error;
}
}
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 权限检查:如果不是分享链接访问,检查查看权限
const isShare = isShareLinkAccess(request);
if (!isShare) {
const session = await getServerSession(authOptions);
if (session?.user) {
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
const userId = (session.user as any).id;
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
const canView = await canUserViewMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
if (!canView) {
return NextResponse.json(
{ error: '无权限查看该素材' },
{ status: 403 }
);
}
}
} else {
// 未登录用户且不是分享链接,不允许查看
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 返回素材信息(转换BigInt为字符串)
// 类型断言,确保 user 字段存在
const materialWithUser = material as any;
return NextResponse.json({
id: material.id,
userId: material.userId,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
materialType: material.materialType,
renderer: material.renderer,
authorName: material.authorName,
previewUrl: material.previewUrl,
zipFileName: material.zipFileName,
zipFileSize: material.zipFileSize.toString(),
downloadCount: material.downloadCount,
likeCount: material.likeCount,
displayImages: material.displayImages,
createdAt: material.createdAt,
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
productCategoryName: materialWithUser.productCategoryName || '',
materialType: materialWithUser.materialType,
renderer: materialWithUser.renderer,
authorName: materialWithUser.authorName,
previewUrl: materialWithUser.previewUrl,
zipFileName: materialWithUser.zipFileName,
zipFileSize: materialWithUser.zipFileSize.toString(),
downloadCount: materialWithUser.downloadCount,
likeCount: materialWithUser.likeCount,
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
createdAt: materialWithUser.createdAt,
videoSourceUrl: materialWithUser.videoSourceUrl || null,
videoPreview540Url: materialWithUser.videoPreview540Url || null,
videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null,
videoCoverUrl: materialWithUser.videoCoverUrl || null,
videoDuration: materialWithUser.videoDuration || null,
videoProcessingStatus: materialWithUser.videoProcessingStatus || null,
user: {
username: material.user.username
}
})
} catch (error: any) {
console.error('获取素材失败:', error)
return NextResponse.json(
{ error: '获取素材失败' },
{ status: 500 }
)
username: materialWithUser.user?.username || '',
},
});
} catch (error) {
console.error('获取素材失败:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}
@ -64,61 +206,97 @@ export async function PUT(
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const body = await request.json()
const { productModel, categoryName, sceneName, materialType, renderer, authorName } = body
// 验证必填字段
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
)
const {
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
materialType,
renderer,
authorName
} = await request.json();
// 使用统一的验证逻辑
// 基础必填字段验证
if (!productModel || !materialType || !authorName) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 });
}
// 获取素材信息
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
return NextResponse.json({ error: '请填写所有必填字段(店铺、场景)' }, { status: 400 });
}
// 标准模型必须选择品类
if (requiresProductCategoryName(materialType) && !productCategoryName) {
return NextResponse.json({ error: '标准模型类型必须选择品类' }, { status: 400 });
}
// renderer 只在3D场景类型时必填
if (requiresRenderer(materialType) && !renderer) {
return NextResponse.json({ error: '3D场景类型必须指定渲染器' }, { status: 400 });
}
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: params.id }
})
where: { id: params.id },
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 权限检查:只有素材上传者或管理员可以编辑
const userFromSession = session.user as any
if (material.userId !== userFromSession.id && userFromSession.role !== 'ADMIN') {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 }
)
// 检查权限:素材所有者、普通管理员或超级管理员都可以编辑
const user = session.user as any;
const userId = user.id;
const userRole = user.role;
// 检查是否是超级管理员
const { isSuperAdmin } = await import('@/lib/department-permission');
const isUserSuperAdmin = await isSuperAdmin(userId);
if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) {
return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 });
}
// 更新素材信息
// 更新素材(包含所有字段,确保数据不丢失)
const updateData: any = {
productModel,
// 使用统一的验证逻辑判断字段是否需要更新
categoryName: requiresCategoryAndScene(materialType) ? categoryName : (categoryName !== undefined ? categoryName : material.categoryName),
sceneName: requiresCategoryAndScene(materialType) ? sceneName : (sceneName !== undefined ? sceneName : material.sceneName),
platformName: platformName !== undefined && platformName !== null ? platformName : (material.platformName || ''),
materialType,
authorName,
updatedAt: new Date(),
};
// 标准模型必须更新 productCategoryName
if (requiresProductCategoryName(materialType)) {
updateData.productCategoryName = productCategoryName || material.productCategoryName;
} else {
updateData.productCategoryName = productCategoryName !== undefined ? productCategoryName : material.productCategoryName;
}
// 使用统一的验证逻辑更新renderer
if (requiresRenderer(materialType)) {
// 3D场景类型:必须提供renderer,如果没有提供则使用默认值或保留原值
updateData.renderer = renderer || material.renderer || getDefaultRenderer(materialType);
} else {
// 非3D场景类型,保持原renderer值(如果有)
updateData.renderer = material.renderer;
}
const updatedMaterial = await prisma.material.update({
where: { id: params.id },
data: {
productModel,
categoryName,
sceneName,
materialType,
renderer,
authorName,
updatedAt: new Date()
}
})
data: updateData,
});
return NextResponse.json({
success: true,
message: '素材信息更新成功',
@ -127,17 +305,15 @@ export async function PUT(
productModel: updatedMaterial.productModel,
categoryName: updatedMaterial.categoryName,
sceneName: updatedMaterial.sceneName,
platformName: updatedMaterial.platformName,
productCategoryName: updatedMaterial.productCategoryName,
materialType: updatedMaterial.materialType,
renderer: updatedMaterial.renderer,
authorName: updatedMaterial.authorName
}
})
} catch (error: any) {
console.error('更新素材失败:', error)
return NextResponse.json(
{ error: '更新素材失败' },
{ status: 500 }
)
authorName: updatedMaterial.authorName,
},
});
} catch (error) {
console.error('更新素材失败:', error);
return NextResponse.json({ error: '更新素材失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,71 @@
'use server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
// 下载单个 SKU 的压缩包
export async function GET(
request: NextRequest,
{ params }: { params: { id: string; skuId: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
// 检查用户下载权限
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { canDownload: true }
})
if (!user?.canDownload) {
return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 })
}
const { skuId } = params
// 获取 SKU 信息
const sku = await prisma.materialSku.findUnique({
where: { id: skuId }
})
if (!sku) {
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
}
if (!sku.filePath) {
return NextResponse.json({ error: 'SKU 没有可下载的文件' }, { status: 404 })
}
// 构建文件路径
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error(`SKU 文件不存在: ${filePath}`)
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
}
// 读取文件
const fileBuffer = fs.readFileSync(filePath)
const fileName = `${sku.skuName}.zip`
// 返回文件流
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': fileBuffer.length.toString()
}
})
} catch (error) {
console.error('下载 SKU 失败:', error)
return NextResponse.json({ error: '下载失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,162 @@
'use server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
// 获取单个 SKU 详情
export async function GET(
request: NextRequest,
{ params }: { params: { id: string; skuId: string } }
) {
try {
const { skuId } = params
const sku = await prisma.materialSku.findUnique({
where: { id: skuId }
})
if (!sku) {
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
}
return NextResponse.json({
...sku,
fileSize: sku.fileSize.toString()
})
} catch (error) {
console.error('获取 SKU 详情失败:', error)
return NextResponse.json({ error: '获取 SKU 详情失败' }, { status: 500 })
}
}
// 更新 SKU
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string; skuId: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { skuId } = params
// 检查 SKU 是否存在,并获取关联的素材信息用于权限检查
const existingSku = await prisma.materialSku.findUnique({
where: { id: skuId },
include: {
material: {
select: {
userId: true // 获取素材的上传者ID
}
}
}
})
if (!existingSku) {
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
}
// 检查权限:只有素材上传者或管理员可以编辑 SKU
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限编辑此 SKU' }, { status: 403 })
}
const body = await request.json()
const { skuName, filePath, fileSize, sortOrder } = body
// 构建更新数据
const updateData: any = {}
if (skuName !== undefined) updateData.skuName = skuName
if (filePath !== undefined) updateData.filePath = filePath
if (fileSize !== undefined) updateData.fileSize = BigInt(fileSize)
if (sortOrder !== undefined) updateData.sortOrder = sortOrder
const updatedSku = await prisma.materialSku.update({
where: { id: skuId },
data: updateData
})
return NextResponse.json({
...updatedSku,
fileSize: updatedSku.fileSize.toString() // 返回时使用正确的字段名
})
} catch (error) {
console.error('更新 SKU 失败:', error)
return NextResponse.json({ error: '更新 SKU 失败' }, { status: 500 })
}
}
// 删除 SKU
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string; skuId: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { skuId } = params
// 检查 SKU 是否存在,并获取关联的素材信息用于权限检查
const existingSku = await prisma.materialSku.findUnique({
where: { id: skuId },
include: {
material: {
select: {
userId: true // 获取素材的上传者ID
}
}
}
})
if (!existingSku) {
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
}
// 检查权限:只有素材上传者或管理员可以删除 SKU
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限删除此 SKU' }, { status: 403 })
}
// 删除本地压缩包文件
if (existingSku.filePath) {
const fullPath = path.join(process.cwd(), 'uploads', existingSku.filePath)
if (fs.existsSync(fullPath)) {
try {
fs.unlinkSync(fullPath)
console.log(`已删除 SKU 压缩包: ${fullPath}`)
} catch (err) {
console.error(`删除 SKU 压缩包失败: ${fullPath}`, err)
}
}
}
// 删除 SKU 记录
await prisma.materialSku.delete({
where: { id: skuId }
})
return NextResponse.json({ success: true, message: 'SKU 已删除' })
} catch (error) {
console.error('删除 SKU 失败:', error)
return NextResponse.json({ error: '删除 SKU 失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,63 @@
'use server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 批量更新 SKU 排序
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, userId: true }
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
// 检查权限:素材上传者或管理员可以调整排序
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限调整排序' }, { status: 403 })
}
const body = await request.json()
const { skuIds } = body // 按新顺序排列的 SKU ID 数组
if (!Array.isArray(skuIds) || skuIds.length === 0) {
return NextResponse.json({ error: '请提供 SKU ID 列表' }, { status: 400 })
}
// 批量更新排序
const updates = skuIds.map((skuId: string, index: number) =>
prisma.materialSku.update({
where: { id: skuId },
data: { sortOrder: index }
})
)
await prisma.$transaction(updates)
return NextResponse.json({ success: true, message: '排序已更新' })
} catch (error) {
console.error('更新 SKU 排序失败:', error)
return NextResponse.json({ error: '更新排序失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,110 @@
'use server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 获取素材的所有 SKU 列表
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const materialId = params.id
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, materialType: true }
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
// 获取 SKU 列表,按排序顺序
const skus = await prisma.materialSku.findMany({
where: { materialId },
orderBy: { sortOrder: 'asc' }
})
// 转换 BigInt 为字符串(JSON 序列化需要)
const skusWithStringSize = skus.map(sku => ({
...sku,
fileSize: sku.fileSize.toString() // 使用正确的字段名
}))
return NextResponse.json({
skus: skusWithStringSize,
total: skus.length
})
} catch (error) {
console.error('获取 SKU 列表失败:', error)
return NextResponse.json({ error: '获取 SKU 列表失败' }, { status: 500 })
}
}
// 添加新的 SKU
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
// 检查素材是否存在且是标准模型类型
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, materialType: true }
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型才能添加 SKU' }, { status: 400 })
}
const body = await request.json()
const { skuName, filePath, fileSize } = body
// 验证必填字段(只需要 skuName 和 filePath)
if (!skuName || !filePath) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 })
}
// 获取当前最大排序值
const maxSortOrder = await prisma.materialSku.aggregate({
where: { materialId },
_max: { sortOrder: true }
})
const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1
// 创建 SKU(只使用模型中存在的字段)
const sku = await prisma.materialSku.create({
data: {
materialId,
skuName,
filePath,
fileSize: BigInt(fileSize || 0),
sortOrder: newSortOrder
}
})
return NextResponse.json({
...sku,
fileSize: sku.fileSize.toString() // 返回时使用正确的字段名
}, { status: 201 })
} catch (error) {
console.error('添加 SKU 失败:', error)
return NextResponse.json({ error: '添加 SKU 失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { existsSync } from 'fs'
// SKU文件上传API
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
// 检查素材是否存在且是标准模型类型
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, materialType: true, userId: true }
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型才能上传 SKU' }, { status: 400 })
}
// 检查权限
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (material.userId !== session.user.id && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限上传 SKU' }, { status: 403 })
}
const formData = await request.formData()
const skuName = formData.get('skuName') as string
const file = formData.get('file') as File
if (!skuName || !file) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 })
}
// 创建上传目录
const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId)
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
// 生成文件名
const timestamp = Date.now()
const ext = path.extname(file.name)
const fileName = `${skuName}_${timestamp}${ext}`
const filePath = path.join(uploadDir, fileName)
// 保存文件
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(filePath, buffer)
// 相对路径(用于数据库存储)
const relativePath = path.join('skus', materialId, fileName)
// 获取当前最大排序值
const maxSortOrder = await prisma.materialSku.aggregate({
where: { materialId },
_max: { sortOrder: true }
})
const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1
// 创建 SKU 记录
const sku = await prisma.materialSku.create({
data: {
materialId,
skuName,
filePath: relativePath,
fileSize: BigInt(file.size),
sortOrder: newSortOrder
}
})
return NextResponse.json({
...sku,
fileSize: sku.fileSize.toString()
}, { status: 201 })
} catch (error) {
console.error('上传 SKU 失败:', error)
return NextResponse.json({ error: '上传 SKU 失败' }, { status: 500 })
}
}

View File

@ -1,112 +1,476 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { readFile, access } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { access, stat } from 'fs/promises';
import { createReadStream } from 'fs';
import { Readable } from 'stream';
import path from 'path';
import { lookup, extension } from 'mime-types';
import JSZip from 'jszip';
import {
canUserDownloadMaterial,
isShareLinkAccess,
} from '@/lib/department-permission';
export async function GET(req: NextRequest) {
try {
// Check if user is logged in
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
function isHttpUrl(url?: string | null): boolean {
if (!url || typeof url !== 'string') return false;
return /^https?:\/\//i.test(url.trim());
}
// Check if user has download permission (skip check for admins)
const userId = (session.user as any).id
const userRole = (session.user as any).role
if (userRole !== 'ADMIN') {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true }
})
// 动态打包displayImages为ZIP(用于模特类素材)
async function buildDynamicZipFromDisplayImages(material: any) {
const displayImages = Array.isArray(material.displayImages)
? material.displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0)
: [];
if (!user || !user.canDownload) {
return NextResponse.json(
{ error: '您的下载权限已被限制,请联系管理员' },
{ status: 403 }
)
if (displayImages.length === 0) {
console.warn('[Download API] No displayImages available for dynamic zip, materialId:', material.id);
return NextResponse.json(
{ error: '该素材没有可下载的文件(可能只包含图片)' },
{ status: 404 }
);
}
console.log(`[Download API] Building ZIP from displayImages for material ${material.id}, total: ${displayImages.length}`);
const zip = new JSZip();
let addedCount = 0;
for (let i = 0; i < displayImages.length; i++) {
const imageUrl = displayImages[i];
try {
const response = await fetch(imageUrl);
if (!response.ok) {
console.warn(`[Download API] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`);
continue;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (!buffer || buffer.length === 0) {
console.warn(`[Download API] Empty buffer for display image index=${i} url=${imageUrl}, skipping`);
continue;
}
const urlObj = new URL(imageUrl);
const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_');
zip.file(filename, buffer);
addedCount++;
} catch (error) {
console.error(`[Download API] Error while adding display image index=${i} url=${imageUrl} to zip:`, error);
}
}
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (addedCount === 0) {
console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id);
return NextResponse.json(
{ error: '该素材没有可下载的文件(可能只包含图片)' },
{ status: 404 }
);
}
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const material = await prisma.material.findUnique({
where: { id },
})
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'展示图合集',
].filter(Boolean);
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
const filename = `${filenameParts.join('_')}.zip`;
// 增加下载次数
await prisma.material.update({
where: { id },
data: {
downloadCount: {
increment: 1
console.log(
'[Download API] Dynamic ZIP built from displayImages, materialId:',
material.id,
'size:',
(zipBuffer.length / 1024 / 1024).toFixed(2),
'MB'
);
return new NextResponse(zipBuffer as any, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': zipBuffer.length.toString(),
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
}
// GET - 下载素材
export async function GET(request: NextRequest) {
try {
const isShare = isShareLinkAccess(request);
// 如果不是分享链接,需要登录
if (!isShare) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const userId = session.user.id;
const userRole = session.user.role;
// 检查用户级别的下载权限
if (userRole !== 'ADMIN') {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
});
if (!user || !user.canDownload) {
return NextResponse.json(
{ error: '您的下载权限已被限制,请联系管理员' },
{ status: 403 }
);
}
}
})
// Read file from local storage
// 确保使用绝对路径
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Attempting to download file:', filePath)
const { searchParams } = new URL(request.url);
const materialId = searchParams.get('id');
if (!materialId) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
// 查询素材
let material: any;
try {
material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
zipFilePath: true,
zipFileName: true,
productModel: true,
categoryName: true,
sceneName: true,
productCategoryName: true,
authorName: true,
materialType: true,
downloadCount: true,
displayImages: true,
},
});
} catch (error: any) {
if (error?.meta?.column?.includes('deletedAt')) {
console.warn('[Download API] deletedAt field not found, retrying without deletedAt');
material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
zipFilePath: true,
zipFileName: true,
productModel: true,
categoryName: true,
sceneName: true,
productCategoryName: true,
authorName: true,
materialType: true,
downloadCount: true,
displayImages: true,
},
});
} else {
throw error;
}
}
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 权限检查:如果不是分享链接,检查部门级别的下载权限
if (!isShare) {
const session = await getServerSession(authOptions);
if (session?.user) {
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
const userId = (session.user as any).id;
// 超级管理员不受限制
const { isSuperAdmin } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
const canDownload = await canUserDownloadMaterial(
userDepartment,
userRole,
managedDepartment,
material.materialType,
userId
);
if (!canDownload) {
return NextResponse.json(
{ error: '无权限下载该素材' },
{ status: 403 }
);
}
}
}
}
// 增加下载次数(仅非分享链接时)
if (!isShare) {
try {
await prisma.material.update({
where: { id: materialId },
data: { downloadCount: { increment: 1 } },
});
} catch (error: any) {
if (error?.meta?.column?.includes('deletedAt')) {
console.warn('[Download API] deletedAt field not found in update, retrying without deletedAt');
await prisma.material.update({
where: { id: materialId },
data: { downloadCount: { increment: 1 } },
});
} else {
throw error;
}
}
}
// 检查是否有ZIP文件;若无,则处理S3直链(音频等存放在zipFileName的场景),否则再尝试动态打包
if (!material.zipFilePath || material.zipFilePath.trim() === '') {
const zipUrl = (material.zipFileName || '').trim();
// 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL
if (zipUrl && isHttpUrl(zipUrl)) {
console.log('[Download API] zipFilePath empty, proxy S3 url from zipFileName:', zipUrl);
const urlObj = new URL(zipUrl);
const extFromUrl = path.extname(urlObj.pathname) || '';
const mimeType = lookup(extFromUrl) || 'application/octet-stream';
// 生成下载文件名(与本地下载保持一致的命名逻辑)
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'源文件',
].filter(Boolean);
const finalExt = extFromUrl || '.bin';
const filename = `${filenameParts.join('_')}${finalExt}`;
try {
const response = await fetch(zipUrl);
if (!response.ok || !response.body) {
console.error('[Download API] Failed to fetch S3 url from zipFileName:', zipUrl, 'status:', response.status);
return NextResponse.json(
{ error: '文件不存在或无法访问' },
{ status: 404 }
);
}
// 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
// 不设置 Content-Length,因 S3 直链已被代理流式返回
},
});
} catch (error) {
console.error('[Download API] Error proxying S3 url from zipFileName:', zipUrl, error);
return NextResponse.json(
{ error: '文件下载失败,请稍后再试' },
{ status: 500 }
);
}
}
console.log('[Download API] Material has no zipFilePath, try dynamic zip if supported. type=', material.materialType);
// 支持动态打包的素材类型
const supportedTypes = [
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'品牌VI灵感',
];
if (
supportedTypes.includes(material.materialType) &&
Array.isArray(material.displayImages) &&
material.displayImages.length > 0
) {
return buildDynamicZipFromDisplayImages(material);
}
return NextResponse.json(
{ error: '该素材没有可下载的文件(可能只包含图片)' },
{ status: 404 }
);
}
// 处理文件路径
const isOldMaterial =
material.zipFilePath.includes('103.118.245.6') ||
material.zipFilePath.startsWith('http');
let filePath = material.zipFilePath;
if (!isOldMaterial) {
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath);
}
}
console.log('Attempting to download file:', filePath, '(Old material:', isOldMaterial, ')');
// 检查文件是否存在
try {
await access(filePath)
await access(filePath);
} catch (error) {
console.error('File not found:', filePath)
console.error('File not found:', filePath, 'error:', error);
// 如果本地文件不存在,尝试动态打包
const supportedTypes = [
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'品牌VI灵感',
];
if (
supportedTypes.includes(material.materialType) &&
Array.isArray(material.displayImages) &&
material.displayImages.length > 0
) {
console.log('[Download API] Local file missing, fallback to dynamic zip from displayImages, materialId:', material.id);
return buildDynamicZipFromDisplayImages(material);
}
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
);
}
const fileBuffer = await readFile(filePath)
// 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名
// 获取原文件扩展名
const originalExt = path.extname(material.zipFileName) // 例如 .zip 或 .7z
const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}`
console.log('Download filename:', newFileName)
// 获取文件大小
const fileStats = await stat(filePath);
const fileSize = fileStats.size;
return new NextResponse(fileBuffer as any, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(newFileName)}"`,
'Content-Length': fileBuffer.length.toString(),
// 确定文件扩展名和MIME类型
const originalExt = material.zipFileName ? path.extname(material.zipFileName) : '';
const actualExt = path.extname(filePath);
const ext = originalExt || actualExt;
const mimeType = lookup(ext || filePath) || 'application/octet-stream';
const mimeExt = extension(mimeType);
const finalExt = ext || (mimeExt ? `.${mimeExt}` : '');
// 生成下载文件名
const filenameParts = [
material.productModel || 'unknown',
material.categoryName || '',
material.sceneName || '',
material.productCategoryName || '',
'源文件',
].filter(Boolean);
const filename = `${filenameParts.join('_')}${finalExt || '.bin'}`;
console.log('Download filename:', filename, 'size:', fileSize, 'mime:', mimeType);
// 处理Range请求(断点续传)
const rangeHeader = request.headers.get('range');
if (rangeHeader) {
const rangePrefix = 'bytes=';
if (!rangeHeader.startsWith(rangePrefix)) {
return new NextResponse('Range header format is invalid', { status: 416 });
}
const rangeParts = rangeHeader.replace(rangePrefix, '').split('-');
const start = parseInt(rangeParts[0], 10);
const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) {
return new NextResponse('Range header out of bounds', { status: 416 });
}
const chunkSize = end - start + 1;
const fileStream = createReadStream(filePath, { start, end });
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(stream, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
}
// 正常下载
const fileStream = createReadStream(filePath);
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
})
cancel() {
fileStream.destroy();
},
});
return new NextResponse(stream, {
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': fileSize.toString(),
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
},
});
} catch (error) {
console.error('Download error:', error)
console.error('Download error:', error);
return NextResponse.json(
{ error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ status: 500 }
)
);
}
}

View File

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// POST - 收藏/取消收藏
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const { materialId } = await request.json();
if (!materialId) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
const userId = session.user.id;
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId },
});
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 检查是否已收藏
const existingFavorite = await prisma.materialFavorite.findFirst({
where: {
materialId,
userId,
},
});
if (existingFavorite) {
// 取消收藏
await prisma.materialFavorite.delete({
where: {
id: existingFavorite.id,
},
});
return NextResponse.json({
message: '已取消收藏',
isFavorited: false,
});
} else {
// 收藏
await prisma.materialFavorite.create({
data: {
materialId,
userId,
},
});
return NextResponse.json({
message: '已收藏',
isFavorited: true,
});
}
} catch (error: any) {
// 兼容旧版本数据库(没有material_favorites表)
if (error?.meta?.table?.includes('material_favorites')) {
return NextResponse.json(
{ error: '收藏功能需要先运行数据库迁移' },
{ status: 400 }
);
}
console.error('[Materials] FAVORITE error:', error);
return NextResponse.json({ error: '操作失败' }, { status: 500 });
}
}

View File

@ -1,93 +1,69 @@
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';
// 点赞/取消点赞
export async function POST(req: NextRequest) {
// POST - 点赞/取消点赞
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const { materialId } = await req.json()
const { materialId } = await request.json();
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
const userId = (session.user as any).id
const userId = session.user.id;
// 检查是否已点赞
const existingLike = await prisma.materialLike.findUnique({
const existingLike = await prisma.materialLike.findFirst({
where: {
materialId_userId: {
materialId,
userId
}
}
})
materialId,
userId,
},
});
if (existingLike) {
// 取消点赞
await prisma.materialLike.delete({
where: {
id: existingLike.id
}
})
where: { id: existingLike.id },
});
// 减少点赞次数
await prisma.material.update({
where: { id: materialId },
data: {
likeCount: {
decrement: 1
}
}
})
data: { likeCount: { decrement: 1 } },
});
return NextResponse.json({
return NextResponse.json({
liked: false,
message: '已取消点赞'
})
message: '已取消点赞',
});
} else {
// 添加点赞
// 点赞
await prisma.materialLike.create({
data: {
materialId,
userId
}
})
userId,
},
});
// 增加点赞次数
await prisma.material.update({
where: { id: materialId },
data: {
likeCount: {
increment: 1
}
}
})
data: { likeCount: { increment: 1 } },
});
return NextResponse.json({
return NextResponse.json({
liked: true,
message: '点赞成功'
})
message: '点赞成功',
});
}
} catch (error) {
console.error('Like error:', error)
return NextResponse.json(
{ error: '操作失败' },
{ status: 500 }
)
console.error('Like error:', error);
return NextResponse.json({ error: '操作失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { createMaterialLog } from '@/lib/material-log';
// POST /api/materials/restore - 恢复已删除的素材
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id },
});
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 恢复素材(将 deletedAt 设为 null)
await prisma.material.update({
where: { id },
data: {
deletedAt: null,
},
});
// 记录操作日志
const userId = (session.user as any).id;
await createMaterialLog({
materialId: id,
userId,
action: 'RESTORE',
changes: {
deletedAt: {
oldValue: material.deletedAt?.toISOString() || null,
newValue: null,
},
},
});
return NextResponse.json({ message: '恢复成功' });
} catch (error: any) {
// 兼容旧版本数据库(没有 deletedAt 字段)
if (error?.meta?.column?.includes('deletedAt')) {
return NextResponse.json({ error: '恢复功能需要先运行数据库迁移' }, { status: 400 });
}
console.error('[Materials] RESTORE error:', error);
return NextResponse.json({ error: '恢复失败' }, { status: 500 });
}
}

View File

@ -5,6 +5,10 @@ import { prisma } from '@/lib/prisma';
import { deleteFromS3, isS3Url } from '@/lib/s3';
import { createMaterialLog } from '@/lib/material-log';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
import {
getUserViewableMaterialTypes,
isShareLinkAccess,
} from '@/lib/department-permission';
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
@ -50,11 +54,11 @@ export async function GET(request: NextRequest) {
// 单个素材查询(通过uploadId)
const uploadId = searchParams.get('uploadId');
if (uploadId) {
const material = await prisma.material.findFirst({
const material: any = await prisma.material.findFirst({
where: {
uploadId,
deletedAt: null,
},
} as any,
select: {
id: true,
userId: true,
@ -62,6 +66,8 @@ export async function GET(request: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
@ -70,7 +76,6 @@ export async function GET(request: NextRequest) {
downloadCount: true,
likeCount: true,
createdAt: true,
uploadId: true,
videoSourceUrl: true,
displayImages: true,
videoPreview540Url: true,
@ -84,13 +89,41 @@ export async function GET(request: NextRequest) {
avatar: true,
},
},
},
} as any,
});
if (!material) {
return NextResponse.json({ material: null });
}
// 权限检查:如果不是分享链接访问,检查查看权限
const isShare = isShareLinkAccess(request);
if (!isShare && session?.user) {
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
const userId = (session.user as any).id;
// 超级管理员不受限制
const { isSuperAdmin, canUserViewMaterial } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
const canView = await canUserViewMaterial(
userDepartment,
userRole,
managedDepartment,
material.materialType,
userId
);
if (!canView) {
return NextResponse.json(
{ error: '无权限查看该素材' },
{ status: 403 }
);
}
}
}
// 检查点赞和收藏状态
let isLiked = false;
let isFavorited = false;
@ -107,12 +140,10 @@ export async function GET(request: NextRequest) {
// 检查收藏
try {
const favorite = await prisma.materialFavorite.findUnique({
const favorite = await prisma.materialFavorite.findFirst({
where: {
materialId_userId: {
materialId,
userId,
},
materialId,
userId,
},
});
isFavorited = !!favorite;
@ -123,8 +154,18 @@ export async function GET(request: NextRequest) {
}
}
// 处理previewUrl的fallback逻辑(与反编译文件一致)
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
const normalizedMaterial = normalizeMaterial({
...material,
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
@ -155,7 +196,7 @@ export async function GET(request: NextRequest) {
const where: any = {};
// 软删除过滤
if (searchParams.get('showDeleted') === 'true') {
if (searchParams.get('showDeleted') === 'true' || searchParams.get('deleted') === 'true') {
where.deletedAt = { not: null };
} else {
where.deletedAt = null;
@ -209,6 +250,67 @@ export async function GET(request: NextRequest) {
};
}
// 权限过滤:如果不是分享链接访问,且用户不是超级管理员,则过滤素材类型
const isShare = isShareLinkAccess(request);
if (!isShare && session?.user) {
const userRole = (session.user as any).role;
const userDepartment = (session.user as any).department;
const managedDepartment = (session.user as any).managedDepartment;
// 超级管理员不受限制
const userId = (session.user as any).id;
const { isSuperAdmin } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
const viewableTypes = await getUserViewableMaterialTypes(
userDepartment,
userRole,
managedDepartment,
userId
);
// 如果用户没有查看权限的素材类型,添加过滤条件
if (viewableTypes.length > 0) {
// 如果已经指定了materialType筛选,需要检查是否有权限
if (materialType) {
const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType];
const hasPermission = types.some((t) => viewableTypes.includes(t));
if (!hasPermission) {
// 没有权限查看该类型,返回空结果
return NextResponse.json({
materials: [],
totalPages: 0,
totalCount: 0,
currentPage: page,
});
}
} else {
// 没有指定materialType,过滤掉没有权限的类型
// 需要处理类型映射
const allViewableTypes = new Set<string>();
viewableTypes.forEach((type) => {
allViewableTypes.add(type);
// 添加映射的类型
if (MATERIAL_TYPE_MAP[type]) {
MATERIAL_TYPE_MAP[type].forEach((mappedType) => {
allViewableTypes.add(mappedType);
});
}
});
where.materialType = { in: Array.from(allViewableTypes) };
}
} else {
// 用户没有任何查看权限,返回空结果
return NextResponse.json({
materials: [],
totalPages: 0,
totalCount: 0,
currentPage: page,
});
}
}
}
// 获取总数
let totalCount: number;
try {
@ -227,7 +329,7 @@ export async function GET(request: NextRequest) {
const totalPages = Math.ceil(totalCount / pageSize);
// 排序
const orderBy: any = { createdAt: sortOrder };
let orderBy: any = { createdAt: sortOrder };
if (sortBy === 'downloadCount') {
orderBy = { downloadCount: sortOrder };
}
@ -246,6 +348,8 @@ export async function GET(request: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
@ -269,10 +373,10 @@ export async function GET(request: NextRequest) {
avatar: true,
},
},
},
} as any,
};
// 综合排序不分页
// comprehensive排序时,不设置orderBy、skip、take,获取所有数据后在内存中排序
if (sortBy !== 'comprehensive') {
query.orderBy = orderBy;
query.skip = (page - 1) * pageSize;
@ -280,6 +384,24 @@ export async function GET(request: NextRequest) {
}
materials = await prisma.material.findMany(query);
// comprehensive排序:按下载量和创建时间综合排序
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
// 综合排序:下载量权重70%,创建时间权重30%
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
// 分页
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
} catch (e: any) {
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
@ -296,6 +418,8 @@ export async function GET(request: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
@ -319,11 +443,28 @@ export async function GET(request: NextRequest) {
avatar: true,
},
},
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
});
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
} catch (e2: any) {
if (e2?.meta?.column?.includes('transcode')) {
console.warn('[Materials API] Transcode fields not found, using fallback query');
@ -336,6 +477,8 @@ export async function GET(request: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
@ -356,11 +499,30 @@ export async function GET(request: NextRequest) {
username: true,
},
},
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
})).map(m => ({
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
}));
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
materials = materials.map(m => ({
...m,
transcodeLastError: null,
transcodeAttempts: 0,
@ -380,6 +542,8 @@ export async function GET(request: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
renderer: true,
authorName: true,
@ -400,19 +564,38 @@ export async function GET(request: NextRequest) {
username: true,
},
},
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
})).map(m => ({
...m,
transcodeLastError: null,
transcodeAttempts: 0,
}));
} else {
throw e;
}
}
} as any,
...(sortBy !== 'comprehensive' ? {
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
} : {}),
}));
// comprehensive排序处理
if (sortBy === 'comprehensive') {
materials.sort((a, b) => {
const downloadScoreA = (a.downloadCount || 0) * 0.7;
const downloadScoreB = (b.downloadCount || 0) * 0.7;
const timeScoreA = new Date(a.createdAt).getTime() * 0.3;
const timeScoreB = new Date(b.createdAt).getTime() * 0.3;
const scoreA = downloadScoreA + timeScoreA;
const scoreB = downloadScoreB + timeScoreB;
return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB;
});
const startIndex = (page - 1) * pageSize;
materials = materials.slice(startIndex, startIndex + pageSize);
}
materials = materials.map(m => ({
...m,
transcodeLastError: null,
transcodeAttempts: 0,
}));
} else {
throw e;
}
}
// 批量查询点赞和收藏状态
const likedSet = new Set<string>();
@ -451,8 +634,18 @@ export async function GET(request: NextRequest) {
// 格式化返回数据
const formattedMaterials = materials.map(material => {
// 处理previewUrl的fallback逻辑(与反编译文件一致)
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.displayImages) {
const displayImagesArray = parseDisplayImages(material.displayImages);
if (displayImagesArray.length > 0) {
previewUrl = displayImagesArray[0];
}
}
const formatted = {
...material,
previewUrl, // 使用处理后的previewUrl
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked: likedSet.has(material.id),
@ -468,6 +661,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
materials: formattedMaterials,
totalPages,
total: totalCount, // 添加total字段以兼容前端
totalCount,
currentPage: page,
});
@ -559,32 +753,32 @@ export async function DELETE(request: NextRequest) {
// 收集所有文件路径
const filesToDelete: string[] = [];
if (material.zipFilePath) {
filesToDelete.push(material.zipFilePath);
if ((material as any).zipFilePath) {
filesToDelete.push((material as any).zipFilePath);
}
if (material.videoSourceUrl && material.videoSourceUrl !== material.zipFilePath) {
filesToDelete.push(material.videoSourceUrl);
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl);
}
if (material.previewUrl) {
filesToDelete.push(material.previewUrl);
if ((material as any).previewUrl) {
filesToDelete.push((material as any).previewUrl);
}
if (material.videoPreview540Url) {
filesToDelete.push(material.videoPreview540Url);
if ((material as any).videoPreview540Url) {
filesToDelete.push((material as any).videoPreview540Url);
}
if (material.videoPreviewClipUrl) {
filesToDelete.push(material.videoPreviewClipUrl);
if ((material as any).videoPreviewClipUrl) {
filesToDelete.push((material as any).videoPreviewClipUrl);
}
if (material.videoCoverUrl) {
filesToDelete.push(material.videoCoverUrl);
if ((material as any).videoCoverUrl) {
filesToDelete.push((material as any).videoCoverUrl);
}
if (Array.isArray(material.displayImages) && material.displayImages.length > 0) {
filesToDelete.push(...material.displayImages.filter(img => img && img.trim()));
if (Array.isArray((material as any).displayImages) && (material as any).displayImages.length > 0) {
filesToDelete.push(...(material as any).displayImages.filter((img: string) => img && img.trim()));
}
// 去重

View File

@ -1,21 +1,45 @@
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';
// Get single material for sharing (公开访问,无需登录)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
if (!displayImages) return [];
if (Array.isArray(displayImages)) {
return displayImages.filter((img: any) => 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: any) => typeof img === 'string' && img.trim().length > 0);
}
} catch (e) {
console.warn('[materials/share] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
// GET /api/materials/share - 获取分享素材信息(无需登录,但登录用户会返回点赞和收藏状态)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
}
// 获取当前登录用户(如果有)
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const material = await prisma.material.findUnique({
where: { id },
select: {
@ -24,6 +48,8 @@ export async function GET(req: NextRequest) {
productModel: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
materialType: true,
authorName: true,
zipFileName: true,
@ -31,30 +57,56 @@ export async function GET(req: NextRequest) {
downloadCount: true,
likeCount: true,
displayImages: true,
createdAt: true
}
})
createdAt: true,
userId: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
videoProcessingStatus: true,
} as any,
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
// 转换BigInt为字符串
const materialData = {
// 如果用户已登录,检查点赞和收藏状态
let isLiked = false;
let isFavorited = false;
if (userId) {
const [like, favorite] = await Promise.all([
prisma.materialLike.findFirst({
where: {
materialId: id,
userId,
},
}),
prisma.materialFavorite.findFirst({
where: {
materialId: id,
userId,
},
}),
]);
isLiked = !!like;
isFavorited = !!favorite;
}
const response = {
...material,
zipFileSize: material.zipFileSize.toString()
}
return NextResponse.json(materialData)
zipFileSize: material.zipFileSize.toString(),
displayImages: parseDisplayImages(material.displayImages || null),
isLiked,
isFavorited,
};
return NextResponse.json(response);
} catch (error) {
console.error('Error fetching material for share:', error)
return NextResponse.json(
{ error: '获取素材失败' },
{ status: 500 }
)
console.error('Error fetching material for share:', error);
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
}
}

View File

@ -1,14 +1,38 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// GET - 获取素材统计数据
// GET - 获取素材统计数据(只统计未删除的素材)
export async function GET() {
try {
// 获取总数
const total = await prisma.material.count();
// 获取总数(只统计未删除的素材)
let total: number;
let materials: Array<{ createdAt: Date }>;
// 获取所有素材的创建时间
const materials = await prisma.material.findMany({
try {
total = await prisma.material.count({
where: {
deletedAt: null,
} as any,
});
// 获取所有未删除素材的创建时间
materials = await prisma.material.findMany({
where: {
deletedAt: null,
} as any,
select: {
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials Stats] deletedAt field not found, using all materials');
total = await prisma.material.count();
materials = await prisma.material.findMany({
select: {
createdAt: true,
},
@ -16,6 +40,10 @@ export async function GET() {
createdAt: 'desc',
},
});
} else {
throw e;
}
}
// 按月份统计
const monthMap = new Map<string, number>();

View File

@ -0,0 +1,198 @@
// 视频转码API - 完整版本(支持自动恢复)
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { transcodeQueue, cleanupTranscodeResidues } from '@/lib/video-transcode-queue';
import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
export const dynamic = 'force-dynamic';
export const maxDuration = 3600; // 1小时
// 恢复标志:确保只执行一次
let recoveryChecked = false;
/**
* 懒加载恢复中断的转码任务
*/
async function ensureRecoveryCompleted(): Promise<void> {
if (recoveryChecked) {
return;
}
recoveryChecked = true;
try {
console.log('[Transcode] Starting recovery check...');
const { recovered, requeued } = await transcodeQueue.recoverInterruptedTasks(
executeTranscodeTask,
cleanupTranscodeResidues
);
console.log(`[Transcode] Recovery completed: ${recovered} tasks recovered, ${requeued} tasks requeued`);
} catch (error) {
console.error('[Transcode] Recovery failed:', error);
// 恢复失败不影响正常流程,重置标志以便下次重试
recoveryChecked = false;
}
}
// GET - 查询转码状态
export async function GET(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务(包括 GET 请求)
await ensureRecoveryCompleted();
try {
const { searchParams } = new URL(request.url);
const materialId = searchParams.get('materialId');
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
);
}
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
videoProcessingStatus: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoDuration: true,
transcodeLastError: true,
transcodeAttempts: true,
} as any,
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
);
}
// 获取转码队列状态
const queueStatus = transcodeQueue.getStatus();
const isProcessing = queueStatus.processingIds.includes(materialId);
const waitingIndex = queueStatus.waiting.findIndex(id => id === materialId);
const waitingTotal = queueStatus.waiting.length;
return NextResponse.json({
materialId: material.id,
status: material.videoProcessingStatus || 'pending',
hasPreview540: !!material.videoPreview540Url,
hasClip: !!material.videoPreviewClipUrl,
hasCover: !!material.videoCoverUrl,
duration: material.videoDuration,
attempts: material.transcodeAttempts || 0,
lastError: material.transcodeLastError,
progress: isProcessing ? queueStatus.progress.get(materialId) || null : null,
stage: isProcessing ? queueStatus.stages.get(materialId) || null : null,
queue: {
waitingIndex: waitingIndex >= 0 ? waitingIndex + 1 : null,
waitingTotal: waitingTotal,
processingIds: queueStatus.processingIds,
maxConcurrent: queueStatus.maxConcurrent,
},
});
} catch (error) {
console.error('[Transcode] GET error:', error);
return NextResponse.json(
{ error: '查询转码状态失败' },
{ status: 500 }
);
}
}
// POST - 启动转码任务
export async function POST(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务
await ensureRecoveryCompleted();
try {
const { materialId, forceRetry } = await request.json();
if (!materialId) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
);
}
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
materialType: true,
} as any,
});
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
);
}
// 检查是否为视频类型
const videoTypes = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频'];
if (!videoTypes.includes((material as any).materialType)) {
return NextResponse.json(
{ error: '此素材不是视频类型' },
{ status: 400 }
);
}
// 检查是否有源视频
if (!(material as any).videoSourceUrl) {
return NextResponse.json(
{ error: '未找到源视频文件' },
{ status: 400 }
);
}
// 检查转码状态
if ((material as any).videoProcessingStatus === 'processing' && !forceRetry) {
return NextResponse.json(
{ error: '转码任务正在进行中', materialId },
{ status: 409 }
);
}
if ((material as any).videoProcessingStatus === 'done' && !forceRetry) {
return NextResponse.json(
{ message: '转码已完成', materialId },
{ status: 200 }
);
}
// 判断是否需要生成封面图(用户未上传时才生成)
const needsCoverImage = !(material as any).videoCoverUrl;
// 加入转码队列
await transcodeQueue.add(
materialId,
(material as any).videoSourceUrl,
needsCoverImage,
executeTranscodeTask
);
console.log(`[Transcode] Task queued for material ${materialId}`);
return NextResponse.json({
message: '转码任务已启动',
materialId,
status: 'processing',
queueStatus: transcodeQueue.getStatus(),
});
} catch (error) {
console.error('[Transcode] POST error:', error);
return NextResponse.json(
{ error: '转码任务启动失败' },
{ status: 500 }
);
}
}

View File

@ -1,93 +1,128 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadToS3, deleteFromS3 } from '@/lib/s3'
import { processImage, getProcessedFileName } from '@/lib/image-processor'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { deleteFromS3, uploadToS3 } from '@/lib/s3';
import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation';
export async function PUT(req: NextRequest) {
// PUT /api/materials/update - 更新素材(包括预览图)
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const formData = await req.formData()
const id = formData.get('id') as string
const productModel = formData.get('productModel') as string
const categoryName = formData.get('categoryName') as string
const sceneName = formData.get('sceneName') as string
const materialType = (formData.get('materialType') as string) || '3DMAX'
const renderer = (formData.get('renderer') as string) || 'CR'
const authorName = formData.get('authorName') as string
const previewImage = formData.get('previewImage') as File | null
// Validation
if (!id || !productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
return NextResponse.json(
{ error: '所有字段都是必填的' },
{ status: 400 }
)
const formData = await request.formData();
const id = formData.get('id') as string;
const productModel = formData.get('productModel') as string;
const categoryName = formData.get('categoryName') as string;
const sceneName = formData.get('sceneName') as string;
const platformName = formData.get('platformName') as string | null;
const productCategoryName = formData.get('productCategoryName') as string | null;
const materialType = (formData.get('materialType') as string) || '3D场景';
const renderer = formData.get('renderer') as string | null;
const authorName = formData.get('authorName') as string;
const previewImage = formData.get('previewImage') as File | null;
// 使用统一的验证逻辑
// 基础必填字段验证
if (!id || !productModel || !materialType || !authorName) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 });
}
// Get existing material
const existingMaterial = await prisma.material.findUnique({
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
return NextResponse.json({ error: '请填写所有必填字段(店铺、场景)' }, { status: 400 });
}
// 标准模型必须选择品类
if (requiresProductCategoryName(materialType) && !productCategoryName) {
return NextResponse.json({ error: '标准模型类型必须选择品类' }, { status: 400 });
}
// renderer 只在3D场景类型时必填
if (requiresRenderer(materialType) && !renderer) {
return NextResponse.json({ error: '3D场景类型必须指定渲染器' }, { status: 400 });
}
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id },
})
if (!existingMaterial) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
});
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
}
let previewUrl = existingMaterial.previewUrl
// If new preview image is uploaded
// 检查权限:素材所有者、普通管理员或超级管理员都可以编辑
const user = session.user as any;
const userId = user.id;
const userRole = user.role;
// 检查是否是超级管理员
const { isSuperAdmin } = await import('@/lib/department-permission');
const isUserSuperAdmin = await isSuperAdmin(userId);
if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) {
return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 });
}
let previewUrl = material.previewUrl;
// 如果上传了新的预览图
if (previewImage && previewImage.size > 0) {
// Delete old preview image from S3
// 删除旧的预览图
try {
await deleteFromS3(existingMaterial.previewUrl)
await deleteFromS3(material.previewUrl);
} catch (error) {
console.error('Error deleting old preview:', error)
console.error('Error deleting old preview:', error);
}
// 处理预览图:压缩到300KB以内,转换为webP,缩放到600px
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
const processedBuffer = await processImage(originalBuffer, {
// 压缩并上传新的预览图
const imageBuffer = Buffer.from(await previewImage.arrayBuffer());
const compressedBuffer = await compressImage(imageBuffer, {
maxSizeKB: 300,
maxWidth: 600,
format: 'webp',
})
// Upload processed preview image to S3
const processedFileName = getProcessedFileName(previewImage.name)
const previewFileName = `preview-${Date.now()}-${processedFileName}`
previewUrl = await uploadToS3(
processedBuffer,
previewFileName,
'image/webp'
)
});
const sanitizedName = sanitizeFileName(previewImage.name);
const fileName = `preview-${Date.now()}-${sanitizedName}`;
previewUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp');
}
// Update material record
const material = await prisma.material.update({
// 更新素材(包含所有字段,确保数据不丢失)
const updateData: any = {
previewUrl,
productModel,
// 使用统一的验证逻辑判断字段是否需要更新
categoryName: requiresCategoryAndScene(materialType) ? (categoryName || material.categoryName) : (categoryName !== undefined && categoryName !== null ? categoryName : material.categoryName),
sceneName: requiresCategoryAndScene(materialType) ? (sceneName || material.sceneName) : (sceneName !== undefined && sceneName !== null ? sceneName : material.sceneName),
platformName: platformName !== undefined && platformName !== null ? platformName : (material.platformName || ''),
materialType,
authorName,
};
// 标准模型必须更新 productCategoryName
if (requiresProductCategoryName(materialType)) {
updateData.productCategoryName = productCategoryName || material.productCategoryName;
} else {
updateData.productCategoryName = productCategoryName !== undefined && productCategoryName !== null ? productCategoryName : material.productCategoryName;
}
// 使用统一的验证逻辑更新renderer
if (requiresRenderer(materialType)) {
// 3D场景类型:必须提供renderer,如果没有提供则使用默认值或保留原值
updateData.renderer = renderer || material.renderer || getDefaultRenderer(materialType);
} else {
// 非3D场景类型,保持原renderer值(如果有)
updateData.renderer = material.renderer;
}
const updatedMaterial = await prisma.material.update({
where: { id },
data: {
previewUrl,
productModel,
categoryName,
sceneName,
materialType,
renderer,
authorName,
},
data: updateData,
include: {
user: {
select: {
@ -95,24 +130,19 @@ export async function PUT(req: NextRequest) {
},
},
},
})
// 转换BigInt为字符串
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
}
});
const response = {
...updatedMaterial,
zipFileSize: updatedMaterial.zipFileSize.toString(),
};
return NextResponse.json(
{ message: '更新成功', material: materialData },
{ message: '更新成功', material: response },
{ status: 200 }
)
);
} catch (error) {
console.error('Update error:', error)
return NextResponse.json(
{ error: '更新失败,请稍后再试' },
{ status: 500 }
)
console.error('Update error:', error);
return NextResponse.json({ error: '更新失败,请稍后再试' }, { status: 500 });
}
}

View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getUploadSession, updateChunk } from '@/lib/upload-sessions'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
// Configure route to handle large files
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800 // 3 hours timeout
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const formData = await req.formData()
const uploadId = formData.get('uploadId') as string
const chunkIndex = parseInt(formData.get('chunkIndex') as string)
const chunk = formData.get('chunk') as File
const fileName = formData.get('fileName') as string
if (!uploadId || isNaN(chunkIndex) || !chunk) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
}
// 获取上传会话(使用统一的接口,自动选择Redis或内存Map)
const sessionData = await getUploadSession(uploadId)
if (!sessionData) {
return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 })
}
// 验证用户
if (sessionData.userId !== (session.user as any).id) {
return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 })
}
// 保存块到临时文件
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
await mkdir(tempDir, { recursive: true })
const chunkFilePath = path.join(tempDir, `chunk-${chunkIndex}`)
const chunkBuffer = Buffer.from(await chunk.arrayBuffer())
await writeFile(chunkFilePath, chunkBuffer)
// 记录已上传的块(使用统一的接口,自动选择Redis或内存Map)
await updateChunk(uploadId, chunkIndex, chunkFilePath)
// 重新获取会话以获取最新的块数量
const updatedSession = await getUploadSession(uploadId)
const uploadedChunksCount = updatedSession?.chunks.size || sessionData.chunks.size + 1
console.log(`[UploadChunk] Saved chunk ${chunkIndex}/${sessionData.totalChunks} for ${uploadId}`)
return NextResponse.json({
success: true,
uploadedChunks: uploadedChunksCount,
totalChunks: sessionData.totalChunks,
})
} catch (error) {
console.error('[UploadChunk] Error:', error)
return NextResponse.json(
{ error: '上传块失败' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,266 @@
// 清理过期或失败的临时上传文件
// 用于清理:1. 过期的上传会话对应的临时文件 2. 失败的上传临时文件 3. 用户取消上传的临时文件
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getUploadSession } from '@/lib/upload-sessions'
import { promises as fs } from 'fs'
import path from 'path'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5分钟
/**
* 清理单个上传会话的临时文件
*/
async function cleanupUploadSession(uploadId: string, uploadDir: string): Promise<{
success: boolean
deletedFiles: number
freedSpace: number
error?: string
}> {
const tempDir = path.join(uploadDir, 'temp', uploadId)
let deletedFiles = 0
let freedSpace = 0
try {
// 检查目录是否存在
try {
await fs.access(tempDir)
} catch {
// 目录不存在,说明已经清理过了
return { success: true, deletedFiles: 0, freedSpace: 0 }
}
// 读取目录中的所有文件
const files = await fs.readdir(tempDir)
for (const file of files) {
const filePath = path.join(tempDir, file)
try {
const stat = await fs.stat(filePath)
if (stat.isFile()) {
await fs.unlink(filePath)
deletedFiles++
freedSpace += stat.size
}
} catch (err) {
console.warn(`[Cleanup] 删除文件失败: ${filePath}`, err)
}
}
// 删除目录本身
try {
await fs.rmdir(tempDir)
} catch (err) {
console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, err)
}
return { success: true, deletedFiles, freedSpace }
} catch (error: any) {
return {
success: false,
deletedFiles,
freedSpace,
error: error.message || '清理失败'
}
}
}
/**
* GET - 清理过期的临时上传文件
* 参数:
* - uploadId: 可选,指定要清理的上传ID
* - expiredOnly: 可选,是否只清理过期的(默认true)
* - maxAge: 可选,最大年龄(小时),默认8小时
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
// 检查是否为管理员(可选,如果需要限制权限)
// const user = await prisma.user.findUnique({ where: { id: session.user.id } })
// if (user?.role !== 'SUPER_ADMIN') {
// return NextResponse.json({ error: '权限不足' }, { status: 403 })
// }
const { searchParams } = new URL(req.url)
const uploadId = searchParams.get('uploadId')
const expiredOnly = searchParams.get('expiredOnly') !== 'false'
const maxAgeHours = parseInt(searchParams.get('maxAge') || '8', 10)
const maxAge = maxAgeHours * 60 * 60 * 1000 // 转换为毫秒
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const tempBaseDir = path.join(uploadDir, 'temp')
// 确保临时目录存在
try {
await fs.access(tempBaseDir)
} catch {
return NextResponse.json({
message: '临时目录不存在,无需清理',
cleaned: 0,
freedSpace: 0
})
}
let cleaned = 0
let totalFreedSpace = 0
const errors: string[] = []
if (uploadId) {
// 清理指定的上传ID
const result = await cleanupUploadSession(uploadId, uploadDir)
if (result.success) {
cleaned = result.deletedFiles
totalFreedSpace = result.freedSpace
} else {
errors.push(result.error || '清理失败')
}
} else {
// 清理所有过期或无效的临时文件
const now = Date.now()
const tempDirs = await fs.readdir(tempBaseDir)
for (const dir of tempDirs) {
const uploadIdFromDir = dir
const tempDir = path.join(tempBaseDir, dir)
try {
// 检查目录
const stat = await fs.stat(tempDir)
if (!stat.isDirectory()) {
continue
}
// 读取元数据文件(如果存在)
const metaPath = path.join(tempDir, 'meta.json')
let createdAt = stat.mtimeMs // 使用目录修改时间作为fallback
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
const meta = JSON.parse(metaContent)
if (meta.createdAt) {
createdAt = meta.createdAt
}
} catch {
// 元数据文件不存在,使用目录修改时间
}
// 检查是否过期
const age = now - createdAt
const isExpired = age > maxAge
// 检查会话是否存在
const session = await getUploadSession(uploadIdFromDir)
const sessionExists = !!session
// 决定是否清理
let shouldCleanup = false
if (expiredOnly) {
// 只清理过期的
shouldCleanup = isExpired
} else {
// 清理所有:过期的 或 会话不存在的
shouldCleanup = isExpired || !sessionExists
}
if (shouldCleanup) {
const result = await cleanupUploadSession(uploadIdFromDir, uploadDir)
if (result.success) {
cleaned += result.deletedFiles
totalFreedSpace += result.freedSpace
console.log(`[Cleanup] 清理上传会话: ${uploadIdFromDir}, 文件数: ${result.deletedFiles}, 释放空间: ${formatBytes(result.freedSpace)}`)
} else {
errors.push(`${uploadIdFromDir}: ${result.error}`)
}
}
} catch (err: any) {
errors.push(`${dir}: ${err.message || '读取失败'}`)
}
}
}
return NextResponse.json({
message: '清理完成',
cleaned,
freedSpace: totalFreedSpace,
freedSpaceFormatted: formatBytes(totalFreedSpace),
errors: errors.length > 0 ? errors : undefined
})
} catch (error: any) {
console.error('[Cleanup] Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 500 }
)
}
}
/**
* POST - 清理指定的上传会话(用于用户取消上传时调用)
*/
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { uploadId } = await req.json()
if (!uploadId) {
return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 })
}
// 验证会话归属
const uploadSession = await getUploadSession(uploadId)
if (uploadSession && uploadSession.userId !== (session.user as any).id) {
return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 })
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const result = await cleanupUploadSession(uploadId, uploadDir)
if (result.success) {
// 删除上传会话
try {
const { deleteUploadSession } = await import('@/lib/upload-sessions')
await deleteUploadSession(uploadId)
} catch (err) {
console.warn('[Cleanup] 删除上传会话失败:', err)
}
return NextResponse.json({
message: '清理成功',
deletedFiles: result.deletedFiles,
freedSpace: result.freedSpace,
freedSpaceFormatted: formatBytes(result.freedSpace)
})
} else {
return NextResponse.json(
{ error: result.error || '清理失败' },
{ status: 500 }
)
}
} catch (error: any) {
console.error('[Cleanup] Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 500 }
)
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}

View File

@ -0,0 +1,114 @@
// 分片上传完成合并接口
// 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径
// 供前端在第二步调用正式上传接口时复用,避免重复上传大文件
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import path from 'path'
import fs from 'fs/promises'
import { createWriteStream, createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import { getUploadSession, deleteUploadSession } from '@/lib/upload-sessions'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800 // 最长3小时
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { uploadId, originalFileName } = await req.json()
if (!uploadId) {
return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 })
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const tempDir = path.join(uploadDir, 'temp', uploadId)
// 读取会话
const uploadSession = await getUploadSession(uploadId)
if (!uploadSession) {
return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 })
}
// 会话归属校验
if (uploadSession.userId !== (session.user as any).id) {
return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 })
}
const totalChunks = uploadSession.totalChunks
const fileSize = uploadSession.fileSize
// 收集分片文件
const chunkFiles = []
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(tempDir, `chunk-${i}`)
chunkFiles.push(chunkPath)
}
// 校验分片是否齐全
for (const chunkPath of chunkFiles) {
try {
await fs.access(chunkPath)
} catch {
return NextResponse.json({ error: '分片不完整,请重新上传' }, { status: 400 })
}
}
// 合并文件
await fs.mkdir(tempDir, { recursive: true })
const mergedFileName = originalFileName || uploadSession.fileName || `upload-${uploadId}`
const mergedPath = path.join(tempDir, `merged-${mergedFileName}`)
const mergedStream = createWriteStream(mergedPath)
for (const chunkPath of chunkFiles) {
const chunkStream = createReadStream(chunkPath)
await pipeline(chunkStream, mergedStream, { end: false })
}
// 手动结束写入并等待关闭
mergedStream.end()
await new Promise<void>((resolve, reject) => {
mergedStream.on('close', () => resolve())
mergedStream.on('error', (err) => reject(err))
})
// 校验大小
const stat = await fs.stat(mergedPath)
if (stat.size !== fileSize) {
return NextResponse.json(
{ error: `合并后的文件大小不匹配,期望 ${fileSize} 实际 ${stat.size}` },
{ status: 400 }
)
}
// 清理分片,保留合并后的文件供后续正式上传使用
await Promise.all(
chunkFiles.map(async (file) => {
try {
await fs.unlink(file)
} catch (err) {
console.warn('[UploadComplete] 清理分片失败(忽略):', err)
}
})
)
// 不立刻删除会话,方便后续调试。交由调用方完成正式入库后再清理。
// 返回合并结果
return NextResponse.json({
message: '分片合并完成',
existingFilePath: mergedPath,
fileName: mergedFileName,
fileSize: stat.size,
uploadId,
})
} catch (error) {
console.error('[UploadComplete] Error:', error)
return NextResponse.json({ error: '合并分片失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { saveUploadSession } from '@/lib/upload-sessions'
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { fileName, fileSize, totalChunks } = await req.json()
if (!fileName || !fileSize || !totalChunks) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
}
// 生成上传ID
const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}`
// 创建临时目录
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'
const tempDir = path.join(uploadDir, 'temp', uploadId)
await mkdir(tempDir, { recursive: true })
// 写入元数据,方便在Redis丢失时回溯
const metaPath = path.join(tempDir, 'meta.json')
const metaPayload = {
fileName,
fileSize,
totalChunks,
userId: (session.user as any).id,
createdAt: Date.now(),
}
await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8')
// 保存会话(使用统一的接口,自动选择Redis或内存Map)
await saveUploadSession(uploadId, {
fileName,
fileSize,
totalChunks,
chunks: new Map(),
createdAt: Date.now(),
userId: (session.user as any).id,
})
console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${totalChunks}`)
return NextResponse.json({ uploadId })
} catch (error) {
console.error('[UploadInit] Error:', error)
return NextResponse.json(
{ error: '初始化上传失败' },
{ status: 500 }
)
}
}

View File

@ -1,341 +1,448 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadToS3 } from '@/lib/s3'
import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomBytes } from 'crypto'
// 素材上传API - 支持多种素材类型
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { promises as fs } from 'fs';
import path from 'path';
import { randomBytes } from 'crypto';
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor';
import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation';
// 生成唯一文件名(避免并发冲突)
function generateUniqueFileName(prefix: string, ext: string): string {
const timestamp = Date.now()
const randomId = randomBytes(8).toString('hex') // 16个字符的随机字符串
return `${prefix}-${timestamp}-${randomId}${ext}`
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10800; // 3小时
// 音频素材占位符预览图(SVG格式)
const AUDIO_PLACEHOLDER = (() => {
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><circle cx="300" cy="180" r="60" fill="#3b82f6" opacity="0.8"/><polygon points="285,155 285,205 325,180" fill="#ffffff"/><text x="50%" y="75%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">音频素材</text></svg>`
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
// 生成唯一文件名
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now();
const random = randomBytes(8).toString('hex');
return `${prefix}-${timestamp}-${random}${ext}`;
}
// Configure route to handle large files
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// Increase timeout for large file uploads
export const maxDuration = 10800 // 3 hours timeout (10800 seconds)
export async function POST(req: NextRequest) {
// POST - 上传素材
export async function POST(request: NextRequest) {
try {
console.log('[Upload] Starting upload process...')
const session = await getServerSession(authOptions)
console.log('[Upload] Session checked:', !!session)
console.log('[Upload] Starting upload process...');
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
);
}
console.log('[Upload] Parsing form data...')
const formData = await req.formData()
const previewImage = formData.get('previewImage') as File
const displayImage = formData.get('displayImage') as File // PSD素材的展示图片
const modelImages = formData.getAll('modelImages') as File[] // 模特类型的多张展示图片
const zipFile = formData.get('zipFile') as File
const productModel = formData.get('productModel') as string
const categoryName = formData.get('categoryName') as string
const sceneName = formData.get('sceneName') as string
const materialType = (formData.get('materialType') as string) || '3DMAX'
const renderer = (formData.get('renderer') as string) || 'CR'
const formData = await request.formData();
const previewImage = formData.get('previewImage') as File | null; // 第一张预览图(主预览图)
const previewImages = formData.getAll('previewImages') as File[]; // 其他预览图(作为展示图)
const displayImage = formData.get('displayImage') as File | null;
const modelImages = formData.getAll('modelImages') as File[];
const displayImagesFromForm = formData.getAll('displayImages') as File[];
const zipFile = formData.get('zipFile') as File | null;
// 支持分片上传后复用服务器已合并的文件
const existingFilePath = (formData.get('existingFilePath') as string) || '';
const originalFileName = (formData.get('originalFileName') as string) || '';
const productModel = formData.get('productModel') as string;
const categoryName = formData.get('categoryName') as string;
const sceneName = formData.get('sceneName') as string;
const platformName = (formData.get('platformName') as string) || '';
const productCategoryName = (formData.get('productCategoryName') as string) || '';
const materialType = (formData.get('materialType') as string) || '3D场景';
const rendererParam = formData.get('renderer') as string | null;
// 统一处理renderer:3D场景必须有renderer,其他类型可选
const renderer = requiresRenderer(materialType)
? (rendererParam || getDefaultRenderer(materialType))
: (rendererParam || '');
console.log('[Upload] Form data received:', {
hasPreviewImage: !!previewImage,
previewImageSize: previewImage?.size,
previewImagesCount: previewImages.length,
hasDisplayImage: !!displayImage,
displayImageSize: displayImage?.size,
modelImagesCount: modelImages.length,
hasZipFile: !!zipFile,
zipFileSize: zipFile?.size,
hasExistingFilePath: !!existingFilePath,
productModel,
categoryName,
sceneName,
materialType
})
materialType,
});
console.log('[Upload] Material type:', materialType)
// Validation - 基础字段验证
if (!productModel || !categoryName || !sceneName) {
console.log('[Upload] Validation failed - missing required fields')
// 使用统一的验证逻辑验证必填字段
if (!productModel) {
return NextResponse.json(
{ error: '产品型号、分类和场景都是必填的' },
{ error: '产品型号是必填的' },
{ status: 400 }
)
);
}
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
return NextResponse.json(
{ error: '请填写所有必填字段(店铺、场景)' },
{ status: 400 }
);
}
// 素材文件验证(模特类型素材文件可选)
if (materialType !== '模特' && !zipFile) {
console.log('[Upload] Validation failed - missing zip file')
// 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件)
const requiresMainFile = !['模特', '标准模型', '买家秀图片', '小红书种草'].includes(materialType);
const hasMainFile = !!zipFile || !!existingFilePath;
if (requiresMainFile && !hasMainFile) {
return NextResponse.json(
{ error: '请上传素材文件' },
{ status: 400 }
)
);
}
// 对于PSD类型,必须有展示图片
if (materialType === 'PSD' && !displayImage) {
console.log('[Upload] Validation failed - PSD requires display image')
return NextResponse.json(
{ error: 'PSD素材必须上传展示图片' },
{ status: 400 }
)
}
// 对于模特类型,必须有至少一张展示图片
if (materialType === '模特' && modelImages.length === 0) {
console.log('[Upload] Validation failed - Model requires model images')
return NextResponse.json(
{ error: '模特素材必须上传至少一张展示图片' },
{ status: 400 }
)
}
// 对于非PSD和非模特类型,必须有预览图
if (materialType !== 'PSD' && materialType !== '模特' && !previewImage) {
console.log('[Upload] Validation failed - non-PSD/Model requires preview image')
return NextResponse.json(
{ error: '请上传预览图' },
{ status: 400 }
)
}
// 获取用户信息作为作者花名
const userId = (session.user as any).id
console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId)
// 获取用户信息
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: session.user.id },
select: { username: true },
})
});
if (!user) {
console.log('[Upload] User not found for ID:', userId)
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
)
);
}
const authorName = user.username
console.log('[Upload] User found:', authorName)
let previewUrl = '';
const displayImages: string[] = [];
let previewUrl: string = ''
// 处理预览图(仅非PSD和非模特类型)
if (materialType !== 'PSD' && materialType !== '模特' && previewImage) {
// 验证预览图大小(限制50MB)
const maxPreviewSize = 50 * 1024 * 1024 // 50MB
if (previewImage.size > maxPreviewSize) {
console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`)
// 处理预览图(第一张作为主预览图)
if (previewImage && !['详情页', '模特'].includes(materialType)) {
if (previewImage.size > 50 * 1024 * 1024) {
return NextResponse.json(
{ error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` },
{ error: `预览图过大,请使用小于50MB的图片` },
{ status: 400 }
)
);
}
// 验证预览图格式
const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
if (!allowedImageTypes.includes(previewImage.type)) {
console.log(`[Upload] Invalid image type: ${previewImage.type}`)
return NextResponse.json(
{ error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
{ status: 400 }
)
}
// 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示
console.log(`[Upload] Processing preview image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`)
try {
const originalBuffer = Buffer.from(await previewImage.arrayBuffer())
const processedBuffer = await processImage(originalBuffer, {
maxSizeKB: 300,
maxWidth: 600,
maxHeight: 800,
format: 'webp',
})
console.log('[Upload] Preview image processed successfully')
// Upload processed preview image to S3
console.log('[Upload] Uploading preview image to S3...')
const processedFileName = getProcessedFileName(previewImage.name)
const fileExt = path.extname(processedFileName)
const previewFileName = generateUniqueFileName('preview', fileExt)
previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp')
console.log('[Upload] Preview image uploaded to S3:', previewUrl)
} catch (imageError) {
console.error('[Upload] Preview image processing error:', imageError)
return NextResponse.json(
{ error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` },
{ status: 500 }
)
}
console.log('[Upload] Processing preview image (1/1)...');
const buffer = Buffer.from(await previewImage.arrayBuffer());
const filename = generateUniqueFilename('preview', path.extname(previewImage.name));
console.log('[Upload] Uploading preview image to S3...');
previewUrl = await uploadToS3(buffer, filename, previewImage.type);
console.log('[Upload] Preview image uploaded:', previewUrl);
}
// Handle PSD display images (切割和上传展示图片)
let displayImages: string[] = []
if (materialType === 'PSD' && displayImage) {
console.log('[Upload] Processing PSD display image...')
try {
// 读取展示图片
const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer())
// 处理其他预览图(作为展示图)- 并行处理以提高速度
if (previewImages.length > 0 && !['详情页', '模特'].includes(materialType)) {
console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`);
// 验证所有图片大小
for (let i = 0; i < previewImages.length; i++) {
if (previewImages[i].size > 50 * 1024 * 1024) {
return NextResponse.json(
{ error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` },
{ status: 400 }
);
}
}
// 并行处理所有预览图
const uploadPromises = previewImages.map(async (image, index) => {
console.log(`[Upload] Processing preview image ${index + 2}/${previewImages.length + 1}...`);
const buffer = Buffer.from(await image.arrayBuffer());
const filename = generateUniqueFilename(`preview-${index + 2}`, path.extname(image.name));
console.log(`[Upload] Uploading preview image ${index + 2} to S3...`);
const url = await uploadToS3(buffer, filename, image.type);
console.log(`[Upload] Preview image ${index + 2} uploaded:`, url);
return url;
});
// 等待所有预览图上传完成
const urls = await Promise.all(uploadPromises);
displayImages.push(...urls);
console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`);
}
// 处理展示图片(详情页)
if (materialType === '详情页' && displayImage) {
const buffer = Buffer.from(await displayImage.arrayBuffer());
const filename = generateUniqueFilename('display', path.extname(displayImage.name));
const url = await uploadToS3(buffer, filename, displayImage.type);
displayImages.push(url);
if (!previewUrl) {
previewUrl = url;
}
console.log('[Upload] Display image uploaded:', url);
}
// 处理模特图片
if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) {
for (let i = 0; i < modelImages.length; i++) {
const image = modelImages[i];
const buffer = Buffer.from(await image.arrayBuffer());
const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name));
const url = await uploadToS3(buffer, filename, image.type);
displayImages.push(url);
// 切割图片(按1500px高度)
console.log('[Upload] Slicing display image (max height: 1500px)...')
const slicedBuffers = await sliceImage(displayImageBuffer, 1500)
console.log(`[Upload] Image sliced into ${slicedBuffers.length} parts`)
// 上传每个切片到S3
for (let i = 0; i < slicedBuffers.length; i++) {
const sliceFileName = generateUniqueFileName(`display-${i + 1}`, '.webp')
const sliceUrl = await uploadToS3(slicedBuffers[i], sliceFileName, 'image/webp')
displayImages.push(sliceUrl)
console.log(`[Upload] Display image slice ${i + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
if (i === 0 && !previewUrl) {
previewUrl = url;
}
// 如果没有预览图,使用第一张展示图片作为预览图
if (!previewImage && displayImages.length > 0) {
previewUrl = displayImages[0]
console.log('[Upload] Using first display image as preview:', previewUrl)
}
} catch (displayImageError) {
console.error('[Upload] Display image processing error:', displayImageError)
return NextResponse.json(
{ error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` },
{ status: 500 }
)
console.log(`[Upload] Model image ${i + 1} uploaded:`, url);
}
}
// Handle Model display images (模特类型的多图处理)
if (materialType === '模特' && modelImages.length > 0) {
console.log(`[Upload] Processing ${modelImages.length} model images...`)
try {
// 处理每张模特图片
for (let i = 0; i < modelImages.length; i++) {
const modelImage = modelImages[i]
console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`)
// 读取图片
const imageBuffer = Buffer.from(await modelImage.arrayBuffer())
// 切割图片(按1500px高度)
const slicedBuffers = await sliceImage(imageBuffer, 1500)
console.log(`[Upload] Model image ${i + 1} sliced into ${slicedBuffers.length} parts`)
// 处理通用展示图(反编译:买家秀/小红书/主图模版/SKU模版/品牌VI 等)
if (displayImagesFromForm.length > 0) {
for (let i = 0; i < displayImagesFromForm.length; i++) {
const image = displayImagesFromForm[i];
const buffer = Buffer.from(await image.arrayBuffer());
const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name));
const url = await uploadToS3(buffer, filename, image.type);
displayImages.push(url);
if (!previewUrl) {
previewUrl = url;
}
console.log(`[Upload] Display image ${i + 1} uploaded:`, url);
}
}
// 上传每个切片到S3
for (let j = 0; j < slicedBuffers.length; j++) {
const sliceFileName = generateUniqueFileName(`model-${i + 1}-${j + 1}`, '.webp')
const sliceUrl = await uploadToS3(slicedBuffers[j], sliceFileName, 'image/webp')
displayImages.push(sliceUrl)
console.log(`[Upload] Model image ${i + 1} slice ${j + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl)
// 处理素材文件(主文件:3D场景/PSD/视频源等)
let zipFileName = '';
let zipFilePath = '';
let zipFileSize = BigInt(0);
if (zipFile || existingFilePath) {
let ext = '';
let buffer: Buffer | null = null;
let sourcePath = '';
if (zipFile) {
ext = path.extname(zipFile.name);
buffer = Buffer.from(await zipFile.arrayBuffer());
zipFileSize = BigInt(buffer.length);
console.log('[Upload] Using uploaded file buffer');
} else {
// 使用分片合并后的服务器侧文件
sourcePath = existingFilePath;
const stat = await fs.stat(sourcePath);
zipFileSize = BigInt(stat.size);
ext = path.extname(originalFileName || sourcePath) || '.zip';
console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size);
}
const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType);
// 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件
const uploadDir = process.env.UPLOAD_DIR || './uploads';
await fs.mkdir(uploadDir, { recursive: true });
// 根据类型决定文件名前缀
const basePrefix = isVideoOrAudio
? materialType === '音乐'
? 'audio-source'
: 'video'
: 'material';
zipFileName = generateUniqueFilename(basePrefix, ext || '.bin');
zipFilePath = path.join(uploadDir, zipFileName);
if (sourcePath && !zipFile) {
// 分片上传场景:直接移动合并后的大文件,避免重复读写 50MB+ 文件
await fs.rename(sourcePath, zipFilePath);
// 已经移动到最终位置,不再额外清理
sourcePath = '';
console.log('[Upload] Moved merged file to final path (no re-copy):', zipFilePath);
} else if (buffer) {
// 直传场景:使用内存中的 buffer 写入
await fs.writeFile(zipFilePath, buffer);
console.log('[Upload] File saved locally:', zipFilePath);
} else {
throw new Error('未找到可写入的素材文件数据');
}
// 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化
if (materialType === '音乐' && zipFile && buffer && isAudioFile(zipFile.type)) {
try {
console.log('[Upload] Audio file detected, parsing metadata and extracting cover image...');
const audioMetadata = await parseAudioMetadata(buffer, zipFile.type);
if (audioMetadata) {
console.log('[Upload] Audio metadata parsed:', {
duration: audioMetadata.duration,
durationFormatted: audioMetadata.durationFormatted,
format: audioMetadata.format,
hasPicture: !!audioMetadata.picture,
});
// 如果音频文件包含封面图,提取并上传到 MinIO 作为预览
if (audioMetadata.picture && !previewUrl) {
try {
const pictureBuffer = audioMetadata.picture.data;
const pictureFormat = audioMetadata.picture.format || 'image/jpeg';
const pictureExt = pictureFormat.includes('png') ? '.png' : '.jpg';
const pictureFilename = generateUniqueFilename('audio-cover', pictureExt);
const pictureUrl = await uploadToS3(pictureBuffer, pictureFilename, pictureFormat);
previewUrl = pictureUrl;
console.log('[Upload] Audio cover image extracted and uploaded:', pictureUrl);
} catch (coverError) {
console.error('[Upload] Failed to upload audio cover image:', coverError);
}
}
}
} catch (audioError) {
console.error('[Upload] Failed to parse audio metadata:', audioError);
// 音频解析失败不影响上传,继续处理
}
}
// 使用第一张图片作为预览图
if (displayImages.length > 0) {
previewUrl = displayImages[0]
console.log('[Upload] Using first model image as preview:', previewUrl)
// 如果是复用服务器合并文件,用后清理以节省空间
if (sourcePath) {
try {
await fs.unlink(sourcePath);
console.log('[Upload] 已清理临时合并文件:', sourcePath);
// 同时清理整个临时目录(如果为空)
const tempDir = path.dirname(sourcePath);
try {
const files = await fs.readdir(tempDir);
if (files.length === 0) {
await fs.rmdir(tempDir);
console.log('[Upload] 已清理空临时目录:', tempDir);
}
} catch (dirError) {
// 忽略目录清理错误
}
} catch (cleanupError) {
console.warn('[Upload] 清理临时合并文件失败(忽略):', cleanupError);
}
} catch (modelImageError) {
console.error('[Upload] Model image processing error:', modelImageError)
return NextResponse.json(
{ error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` },
{ status: 500 }
)
}
}
// Save zip/PSD file locally (if exists - 模特类型可以没有素材文件)
let zipFileName = ''
let zipFilePath = ''
let zipFileSize = BigInt(0)
if (!zipFile) {
console.log('[Upload] No zip file provided (Model type with images only), skipping file save...')
} else {
console.log('[Upload] Saving file locally...')
const uploadDir = process.env.UPLOAD_DIR || './uploads'
await mkdir(uploadDir, { recursive: true })
const zipFileExt = path.extname(zipFile.name)
zipFileName = generateUniqueFileName('material', zipFileExt)
zipFilePath = path.join(uploadDir, zipFileName)
// 读取并保存素材文件
const zipBuffer = Buffer.from(await zipFile.arrayBuffer())
await writeFile(zipFilePath, zipBuffer)
zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节)
console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`)
// 音频素材:如果没有预览图,使用占位符
if (materialType === '音乐' && (!previewUrl || previewUrl.trim() === '')) {
previewUrl = AUDIO_PLACEHOLDER
console.log('[Upload] Audio material without cover image, using audio placeholder')
}
// Create material record
console.log('[Upload] Creating database record...')
// 创建数据库记录(包含所有字段,确保数据不丢失)
// 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放
const videoSourceUrl =
['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName
? `/uploads/${zipFileName}`
: null;
const material = await prisma.material.create({
data: {
previewUrl,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
materialType,
renderer,
authorName,
authorName: user.username,
zipFileName,
zipFilePath,
zipFileSize,
displayImages, // PSD/模特展示图片URLs数组
userId: (session.user as any).id,
},
displayImages,
videoSourceUrl, // 视频类型时保存视频URL
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
userId: session.user.id,
} as any,
include: {
user: {
select: {
username: true,
},
select: { username: true },
},
},
})
console.log('[Upload] Database record created:', material.id)
});
// 转换BigInt为字符串以支持JSON序列化
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString()
console.log('[Upload] Database record created:', material.id);
// 验证文件是否存在(特别是音乐和视频文件)
if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) {
try {
const stats = await fs.stat(zipFilePath);
console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size });
} catch (fileError: any) {
console.error('[Upload] ⚠️ File not found after upload:', {
path: zipFilePath,
error: fileError.message,
materialId: material.id,
});
// 文件不存在,但记录已创建,这会导致播放失败
// 这里只记录错误,不阻止流程,因为可能是其他问题
}
}
console.log('[Upload] Upload completed successfully!')
return NextResponse.json(
{ message: '上传成功', material: materialData },
{ status: 201 }
)
} catch (error) {
console.error('Upload error:', error)
// Log detailed error information
if (error instanceof Error) {
console.error('Error name:', error.name)
console.error('Error message:', error.message)
console.error('Error stack:', error.stack)
// 清理上传会话(如果使用了分片上传)
const uploadId = formData.get('uploadId') as string | null;
if (uploadId) {
try {
const { deleteUploadSession } = await import('@/lib/upload-sessions');
await deleteUploadSession(uploadId);
console.log('[Upload] 已清理上传会话:', uploadId);
} catch (cleanupError) {
console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError);
}
}
// Return more specific error message for debugging
const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试'
// 如果是视频类型素材,上传完成后立即入队触发后台转码(异步)
if (videoSourceUrl) {
const needsCoverImage = !previewUrl || previewUrl.trim() === '';
try {
// 入队转码任务(使用静态导入,确保模块加载成功)
await enqueueVideoTranscodeTask(material.id, videoSourceUrl, needsCoverImage);
console.log('[Upload] ✅ Video transcode task enqueued successfully:', material.id);
} catch (e: any) {
// 入队失败时,记录详细错误信息到数据库
const errorMsg = e?.message || String(e) || '未知错误';
const errorStack = e?.stack ? `\n堆栈: ${e.stack.substring(0, 1000)}` : '';
const fullErrorMsg = `入队失败: ${errorMsg}${errorStack}`;
console.error('[Upload] ❌ Failed to enqueue video transcode task:', {
materialId: material.id,
videoSourceUrl,
error: errorMsg,
stack: e?.stack,
});
// 更新数据库状态,记录错误信息,但保持 pending 状态以便手动重试
try {
await prisma.material.update({
where: { id: material.id },
data: {
videoProcessingStatus: 'pending', // 保持 pending,允许手动重试
transcodeLastError: fullErrorMsg.substring(0, 1000), // 记录详细错误
} as any,
});
console.log('[Upload] ⚠️ Updated material status with enqueue error (status: pending for manual retry)');
} catch (dbError) {
console.error('[Upload] ❌ Failed to update material status:', dbError);
}
}
}
return NextResponse.json(
{
error: '上传失败,请稍后再试',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
{
message: '上传成功',
material: {
...material,
zipFileSize: material.zipFileSize.toString(),
},
},
{ status: 201 }
);
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: '上传失败,请稍后再试' },
{ status: 500 }
)
);
}
}

View File

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getUserViewableMaterialTypes } from '@/lib/department-permission';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const user = session.user as any;
const types = await getUserViewableMaterialTypes(
user.department,
user.role,
user.managedDepartment,
user.id
);
return NextResponse.json({ materialTypes: types });
} catch (error) {
console.error('[Viewable Material Types] Error:', error);
return NextResponse.json({ error: '获取可见素材类型失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,127 @@
// 网络诊断API - 用于测试上传/下载速度
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const maxDuration = 60; // 最长60秒
// 生成测试数据
function generateTestData(size = 10 * 1024 * 1024): Uint8Array {
const data = new Uint8Array(size);
for (let i = 0; i < data.length; i++) {
data[i] = i % 251; // 使用简单的模式填充
}
return data;
}
// GET - 下载测试(返回测试数据)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const mode = searchParams.get('mode') || 'download';
// 下载速度测试
if (mode === 'download') {
const testData = generateTestData();
return new Response(testData as any, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': testData.byteLength.toString(),
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
}
// S3存储测试
if (mode === 'storage') {
const s3Url = getS3PublicUrl();
const startTime = Date.now();
try {
const response = await fetch(s3Url, {
method: 'GET',
redirect: 'manual',
cache: 'no-store',
});
const duration = Date.now() - startTime;
return NextResponse.json({
ok: response.ok,
status: response.status,
durationMs: duration,
url: s3Url,
contentLength: response.headers.get('content-length'),
location: response.headers.get('location'),
});
} catch (error: any) {
return NextResponse.json(
{
ok: false,
error: error?.message || String(error),
url: s3Url,
},
{ status: 500 }
);
}
}
// Ping测试
if (mode === 'ping') {
return NextResponse.json({
ok: true,
message: 'pong',
timestamp: new Date().toISOString(),
});
}
// 默认返回测试数据
const testData = generateTestData();
return new Response(testData as any, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': testData.byteLength.toString(),
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
}
// POST - 上传测试(接收测试数据)
export async function POST(request: NextRequest) {
try {
const data = await request.arrayBuffer();
return NextResponse.json({
ok: true,
receivedBytes: data.byteLength,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
return NextResponse.json(
{
ok: false,
error: error?.message || String(error),
},
{ status: 500 }
);
}
}
// 获取S3公网URL
function getS3PublicUrl(): string {
const s3Domain = process.env.S3_PUBLIC_DOMAIN || process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL;
if (!s3Domain) {
return 'https://s3.yydd.ai';
}
// 确保URL格式正确
if (/^https?:\/\//i.test(s3Domain)) {
return s3Domain.replace(/\/$/, '');
}
return `https://${s3Domain.replace(/\/$/, '')}`;
}

View File

@ -1,105 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* 判断IP是否为内网地址
*/
function isIntranetIP(ip: string): boolean {
if (!ip) return false
// 处理可能存在的端口号
const cleanIP = ip.split(':')[0]
// localhost
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') {
// 判断是否为内网IP地址
function isInternalIP(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return true
}
// IPv4 内网IP段判断
// 判断是否为内网IP段
// 10.0.0.0 - 10.255.255.255
// 172.16.0.0 - 172.31.255.255
// 192.168.0.0 - 192.168.255.255
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
const match = cleanIP.match(ipv4Regex)
const match = hostname.match(ipv4Regex)
if (match) {
const [, a, b, c, d] = match.map(Number)
const [, a, b] = match.map(Number)
// 验证IP有效性
if (a > 255 || b > 255 || c > 255 || d > 255) {
return false
}
// 10.0.0.0 - 10.255.255.255 (Class A)
// 10.x.x.x
if (a === 10) return true
// 172.16.0.0 - 172.31.255.255 (Class B)
// 172.16.x.x - 172.31.x.x
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0 - 192.168.255.255 (Class C)
// 192.168.x.x
if (a === 192 && b === 168) return true
// 169.254.0.0 - 169.254.255.255 (Link-local)
if (a === 169 && b === 254) return true
}
return false
}
/**
* 获取客户端真实IP地址
*/
function getClientIP(request: NextRequest): string {
// 优先级:x-real-ip > x-forwarded-for > connection
const xRealIP = request.headers.get('x-real-ip')
if (xRealIP) return xRealIP
const xForwardedFor = request.headers.get('x-forwarded-for')
if (xForwardedFor) {
// x-forwarded-for 可能包含多个IP,取第一个(客户端真实IP)
return xForwardedFor.split(',')[0].trim()
}
const cfConnectingIP = request.headers.get('cf-connecting-ip') // Cloudflare
if (cfConnectingIP) return cfConnectingIP
const trueClientIP = request.headers.get('true-client-ip') // Akamai
if (trueClientIP) return trueClientIP
// 如果都没有,返回默认值
return 'unknown'
}
/**
* GET /api/network-status
* 返回用户的网络环境状态
*/
// 网络状态检测API
// GET: 检测用户是否在内网,以及是否访问公网域名
export async function GET(request: NextRequest) {
try {
const clientIP = getClientIP(request)
const isIntranet = isIntranetIP(clientIP)
// 获取客户端IP(从请求头中获取,如果通过代理)
const forwarded = request.headers.get('x-forwarded-for')
const realIp = request.headers.get('x-real-ip')
const clientIp = forwarded?.split(',')[0]?.trim() || realIp || 'unknown'
// 获取当前访问的域名
const hostname = request.headers.get('host') || ''
const isPublicDomain = hostname.includes('yydd.ai') || hostname.includes('3d.yydd.ai')
const host = request.headers.get('host') || ''
const hostname = host.split(':')[0] // 移除端口号
console.log(`[Network Status] IP: ${clientIP}, isIntranet: ${isIntranet}, hostname: ${hostname}`)
// 严格判断:只有当clientIp确实是内网IP(不是unknown)时,才认为用户在内网
// 同时hostname也必须是内网IP,双重验证确保准确性
const clientIsIntranet = clientIp !== 'unknown' && isInternalIP(clientIp)
const hostnameIsIntranet = isInternalIP(hostname)
return NextResponse.json({
// 用户在内网:clientIp是内网IP 或 hostname是内网IP(双重验证)
const isIntranet = clientIsIntranet || hostnameIsIntranet
// 判断是否为公网域名(非内网IP的域名,且不是localhost)
// 公网域名特征:不是IP地址格式,且不是localhost/127.0.0.1
const isPublicDomain = !hostnameIsIntranet &&
hostname !== 'localhost' &&
hostname !== '127.0.0.1' &&
!hostname.match(/^192\.168\./) &&
!hostname.match(/^10\./) &&
!hostname.match(/^172\.(1[6-9]|2[0-9]|3[01])\./) &&
// 确保hostname不是IP格式(是域名)
!hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
return NextResponse.json({
success: true,
isIntranet,
isPublicDomain,
// 不返回完整IP地址(隐私保护)
ipMasked: clientIP.split('.').slice(0, 2).join('.') + '.xxx.xxx',
message: isIntranet
? '检测到内网访问'
: '检测到公网访问',
intranetUrl: isIntranet && isPublicDomain
? 'http://192.168.58.20:4320'
: null
clientIp,
hostname,
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('[Network Status] Error:', error)
return NextResponse.json({
success: false,
error: '网络状态检测失败'
}, { status: 500 })
return NextResponse.json(
{
success: false,
error: '网络检测失败'
},
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
// 接收测试数据并立即返回,用于测速
const data = await request.arrayBuffer()
return NextResponse.json({
status: 'ok',
receivedBytes: data.byteLength,
timestamp: new Date().toISOString()
})
} catch (error) {
return NextResponse.json(
{ error: '网络测试失败' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
// 下载测速API
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const size = body.size || 1024 * 1024 // 默认1MB
// 生成测试数据
const buffer = Buffer.alloc(size, 0)
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': size.toString()
}
})
} catch (error) {
console.error('下载测速失败:', error)
return NextResponse.json({ error: '下载测速失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { testS3Connection } from '@/lib/s3'
// 存储连通性测试API
export async function GET() {
try {
// 测试S3连接
const s3Connected = await testS3Connection()
if (s3Connected) {
return NextResponse.json({
success: true,
message: 'S3存储服务连接正常'
})
} else {
return NextResponse.json({
success: false,
message: 'S3存储服务连接失败'
}, { status: 500 })
}
} catch (error) {
console.error('存储连通性测试失败:', error)
return NextResponse.json({
success: false,
message: '存储服务连接失败'
}, { status: 500 })
}
}

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
// 上传测速API
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 })
}
// 读取文件内容(模拟上传处理)
await file.arrayBuffer()
return NextResponse.json({
success: true,
size: file.size,
message: '上传测试完成'
})
} catch (error) {
console.error('上传测速失败:', error)
return NextResponse.json({ error: '上传测速失败' }, { status: 500 })
}
}

View File

@ -1,162 +1,227 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET - 获取所有平台分类(包含数量统计)
// GET - 获取所有平台分类
export async function GET(request: NextRequest) {
try {
const platforms = await prisma.platform.findMany({
orderBy: { createdAt: 'asc' }
})
orderBy: { createdAt: 'asc' },
});
// 获取每个平台的素材数量
// 统计每个平台的素材数量(只统计未删除的素材)
const platformsWithCount = await Promise.all(
platforms.map(async (platform) => {
const count = await prisma.material.count({
where: { platformName: platform.name }
})
let count: number;
try {
count = await prisma.material.count({
where: {
platformName: platform.name,
deletedAt: null,
} as any,
});
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
count = await prisma.material.count({
where: { platformName: platform.name },
});
} else {
throw e;
}
}
return {
id: platform.id,
name: platform.name,
count
}
count,
};
})
)
);
return NextResponse.json(platformsWithCount)
return NextResponse.json(platformsWithCount);
} catch (error) {
console.error('Error fetching platforms:', error)
return NextResponse.json({ error: '获取平台分类失败' }, { status: 500 })
console.error('Error fetching platforms:', error);
return NextResponse.json(
{ error: '获取平台分类失败' },
{ status: 500 }
);
}
}
// POST - 创建新平台分类
// POST - 创建新的平台分类
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
);
}
const { name } = await request.json()
const { name } = await request.json();
if (!name || !name.trim()) {
return NextResponse.json({ error: '平台分类名称不能为空' }, { status: 400 })
return NextResponse.json(
{ error: '平台分类名称不能为空' },
{ status: 400 }
);
}
// 检查是否已存在
const existing = await prisma.platform.findUnique({
where: { name: name.trim() }
})
where: { name: name.trim() },
});
if (existing) {
return NextResponse.json({ error: '该平台分类已存在' }, { status: 400 })
return NextResponse.json(
{ error: '该平台分类已存在' },
{ status: 400 }
);
}
const platform = await prisma.platform.create({
data: { name: name.trim() }
})
data: { name: name.trim() },
});
return NextResponse.json(platform, { status: 201 })
return NextResponse.json(platform, { status: 201 });
} catch (error) {
console.error('Error creating platform:', error)
return NextResponse.json({ error: '创建平台分类失败' }, { status: 500 })
console.error('Error creating platform:', error);
return NextResponse.json(
{ error: '创建平台分类失败' },
{ status: 500 }
);
}
}
// PUT - 更新平台分类
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
);
}
const { id, name } = await request.json()
const { id, name } = await request.json();
if (!id || !name || !name.trim()) {
return NextResponse.json({ error: '平台分类ID和名称不能为空' }, { status: 400 })
return NextResponse.json(
{ error: '平台分类ID和名称不能为空' },
{ status: 400 }
);
}
// 检查是否存在其他同名平台
// 检查新名称是否已被其他分类使用
const existing = await prisma.platform.findFirst({
where: {
where: {
name: name.trim(),
NOT: { id }
}
})
NOT: { id },
},
});
if (existing) {
return NextResponse.json({ error: '该平台分类名称已被使用' }, { status: 400 })
return NextResponse.json(
{ error: '该平台分类名称已被使用' },
{ status: 400 }
);
}
// 获取旧名称
const oldPlatform = await prisma.platform.findUnique({ where: { id } })
const oldPlatform = await prisma.platform.findUnique({
where: { id },
});
if (!oldPlatform) {
return NextResponse.json({ error: '平台分类不存在' }, { status: 404 })
return NextResponse.json(
{ error: '平台分类不存在' },
{ status: 404 }
);
}
// 更新平台分类
const platform = await prisma.platform.update({
// 更新分类
const updated = await prisma.platform.update({
where: { id },
data: { name: name.trim() }
})
data: { name: name.trim() },
});
// 更新所有使用该平台的素材
// 更新所有使用该分类的素材
await prisma.material.updateMany({
where: { platformName: oldPlatform.name },
data: { platformName: name.trim() }
})
data: { platformName: name.trim() },
});
return NextResponse.json(platform)
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating platform:', error)
return NextResponse.json({ error: '更新平台分类失败' }, { status: 500 })
console.error('Error updating platform:', error);
return NextResponse.json(
{ error: '更新平台分类失败' },
{ status: 500 }
);
}
}
// DELETE - 删除平台分类
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
return NextResponse.json(
{ error: '需要管理员权限' },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: '平台分类ID不能为空' }, { status: 400 })
return NextResponse.json(
{ error: '平台分类ID不能为空' },
{ status: 400 }
);
}
// 检查是否有素材使用该平台
const platform = await prisma.platform.findUnique({ where: { id } })
// 检查是否有素材使用该分类
const platform = await prisma.platform.findUnique({
where: { id },
});
if (!platform) {
return NextResponse.json({ error: '平台分类不存在' }, { status: 404 })
return NextResponse.json(
{ error: '平台分类不存在' },
{ status: 404 }
);
}
const materialCount = await prisma.material.count({
where: { platformName: platform.name }
})
where: { platformName: platform.name },
});
if (materialCount > 0) {
return NextResponse.json({
error: `无法删除:有 ${materialCount} 个素材正在使用该平台分类`
}, { status: 400 })
return NextResponse.json(
{ error: `无法删除:有 ${materialCount} 个素材正在使用该平台分类` },
{ status: 400 }
);
}
// 删除平台分类
await prisma.platform.delete({ where: { id } })
await prisma.platform.delete({
where: { id },
});
return NextResponse.json({ message: '平台分类已删除' })
return NextResponse.json({ message: '平台分类已删除' });
} catch (error) {
console.error('Error deleting platform:', error)
return NextResponse.json({ error: '删除平台分类失败' }, { status: 500 })
console.error('Error deleting platform:', error);
return NextResponse.json(
{ error: '删除平台分类失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET /api/product-categories - 获取产品类别列表
export async function GET() {
try {
const categories = await prisma.productCategory.findMany({
orderBy: { name: 'asc' },
});
// 统计每个类别的素材数量
const categoriesWithCount = await Promise.all(
categories.map(async (category) => {
const count = await prisma.material.count({
where: {
productCategoryName: category.name,
deletedAt: null,
},
});
return { ...category, count };
})
);
return NextResponse.json(categoriesWithCount);
} catch (error) {
console.error('获取品类列表失败:', error);
return NextResponse.json({ error: '获取品类列表失败' }, { status: 500 });
}
}
// POST /api/product-categories - 创建产品类别
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const { name } = await request.json();
if (!name || !name.trim()) {
return NextResponse.json({ error: '品类名称不能为空' }, { status: 400 });
}
// 检查是否已存在
const existing = await prisma.productCategory.findUnique({
where: { name: name.trim() },
});
if (existing) {
return NextResponse.json({ error: '该品类已存在' }, { status: 400 });
}
const category = await prisma.productCategory.create({
data: { name: name.trim() },
});
return NextResponse.json(category, { status: 201 });
} catch (error) {
console.error('创建品类失败:', error);
return NextResponse.json({ error: '创建品类失败' }, { status: 500 });
}
}
// PUT /api/product-categories - 更新产品类别
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const { id, name } = await request.json();
if (!id || !name || !name.trim()) {
return NextResponse.json({ error: '参数错误' }, { status: 400 });
}
// 检查新名称是否已被其他类别使用
const existing = await prisma.productCategory.findFirst({
where: {
name: name.trim(),
NOT: { id },
},
});
if (existing) {
return NextResponse.json({ error: '该品类名称已存在' }, { status: 400 });
}
// 获取旧名称
const oldCategory = await prisma.productCategory.findUnique({
where: { id },
});
if (!oldCategory) {
return NextResponse.json({ error: '品类不存在' }, { status: 404 });
}
// 更新类别
const updated = await prisma.productCategory.update({
where: { id },
data: { name: name.trim() },
});
// 更新所有使用该类别的素材
await prisma.material.updateMany({
where: { productCategoryName: oldCategory.name },
data: { productCategoryName: name.trim() },
});
return NextResponse.json(updated);
} catch (error) {
console.error('更新品类失败:', error);
return NextResponse.json({ error: '更新失败' }, { status: 500 });
}
}
// DELETE /api/product-categories - 删除产品类别
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session || session.user?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: '缺少ID参数' }, { status: 400 });
}
await prisma.productCategory.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('删除品类失败:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,148 @@
// 媒体资源代理 API - 解决混合内容问题
// 将 HTTP 资源通过 HTTPS 代理提供给前端
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// 允许的域名白名单
const ALLOWED_DOMAINS = [
's3.1080p.hk',
's3.yydd.ai',
'localhost',
'127.0.0.1',
];
// 允许的文件扩展名
const ALLOWED_EXTENSIONS = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', // 图片
'.mp4', '.webm', '.ogg', // 视频
'.mp3', '.wav', '.ogg', '.m4a', // 音频
];
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const url = searchParams.get('url');
if (!url) {
return NextResponse.json(
{ error: '缺少 url 参数' },
{ status: 400 }
);
}
// 解析 URL
let targetUrl: URL;
try {
targetUrl = new URL(url);
} catch {
return NextResponse.json(
{ error: '无效的 URL' },
{ status: 400 }
);
}
// 检查域名是否在白名单中
const hostname = targetUrl.hostname.toLowerCase();
const isAllowed = ALLOWED_DOMAINS.some(domain =>
hostname === domain || hostname.endsWith(`.${domain}`)
);
if (!isAllowed) {
return NextResponse.json(
{ error: '不允许的域名' },
{ status: 403 }
);
}
// 检查文件扩展名
const pathname = targetUrl.pathname.toLowerCase();
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext =>
pathname.endsWith(ext)
);
if (!hasAllowedExtension) {
return NextResponse.json(
{ error: '不允许的文件类型' },
{ status: 403 }
);
}
// 检查是否有 Range 请求(用于视频播放)
const rangeHeader = request.headers.get('range');
// 构建请求头
const fetchHeaders: HeadersInit = {
'User-Agent': 'Mozilla/5.0 (compatible; MediaProxy/1.0)',
};
// 如果有 Range 请求,转发给源服务器
if (rangeHeader) {
fetchHeaders['Range'] = rangeHeader;
}
// 获取资源
const response = await fetch(targetUrl.toString(), {
headers: fetchHeaders,
// 设置超时
signal: AbortSignal.timeout(30000), // 30秒超时
});
if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的
return NextResponse.json(
{ error: `资源获取失败: ${response.status}` },
{ status: response.status }
);
}
// 获取内容类型
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentLength = response.headers.get('content-length');
const contentRange = response.headers.get('content-range');
const acceptRanges = response.headers.get('accept-ranges');
// 获取响应体
const buffer = await response.arrayBuffer();
// 构建响应头
const responseHeaders: HeadersInit = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
};
// 添加 Range 相关头(如果存在)
if (contentLength) {
responseHeaders['Content-Length'] = contentLength;
}
if (contentRange) {
responseHeaders['Content-Range'] = contentRange;
}
if (acceptRanges) {
responseHeaders['Accept-Ranges'] = acceptRanges;
}
// 返回资源
return new NextResponse(buffer, {
status: response.status, // 可能是 200 或 206
headers: responseHeaders,
});
} catch (error: any) {
console.error('[Proxy Media] Error:', error);
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
return NextResponse.json(
{ error: '请求超时' },
{ status: 504 }
);
}
return NextResponse.json(
{ error: '代理请求失败' },
{ status: 500 }
);
}
}

View File

@ -1,16 +1,20 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// 获取排行榜数据
// GET /api/rankings - 获取排行榜数据
export async function GET() {
try {
// 1. 贡献排行榜(上传素材数量最多)
// 上传榜 - 按素材数量排序(只统计未删除的素材)
const contributors = await prisma.user.findMany({
select: {
username: true,
_count: {
select: {
materials: true,
materials: {
where: {
deletedAt: null,
} as any,
},
},
},
},
@ -20,10 +24,30 @@ export async function GET() {
},
},
take: 10,
})
// 2. 下载排行榜(素材被下载次数最多的作者)
const downloadRankings = await prisma.material.groupBy({
});
// 下载榜 - 按下载次数排序(只统计未删除的素材)
let downloadRankings;
try {
downloadRankings = await prisma.material.groupBy({
by: ['userId'],
where: {
deletedAt: null,
} as any,
_sum: {
downloadCount: true,
},
orderBy: {
_sum: {
downloadCount: 'desc',
},
},
take: 10,
});
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
downloadRankings = await prisma.material.groupBy({
by: ['userId'],
_sum: {
downloadCount: true,
@ -34,23 +58,47 @@ export async function GET() {
},
},
take: 10,
})
const downloadUsers = await Promise.all(
});
} else {
throw e;
}
}
const downloads = await Promise.all(
downloadRankings.map(async (item) => {
const user = await prisma.user.findUnique({
where: { id: item.userId },
select: { username: true },
})
});
return {
username: user?.username || '未知用户',
count: item._sum.downloadCount || 0,
}
};
})
)
// 3. 点赞排行榜(素材被点赞次数最多的作者)
const likeRankings = await prisma.material.groupBy({
);
// 点赞榜 - 按点赞次数排序(只统计未删除的素材)
let likeRankings;
try {
likeRankings = await prisma.material.groupBy({
by: ['userId'],
where: {
deletedAt: null,
} as any,
_sum: {
likeCount: true,
},
orderBy: {
_sum: {
likeCount: 'desc',
},
},
take: 10,
});
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
likeRankings = await prisma.material.groupBy({
by: ['userId'],
_sum: {
likeCount: true,
@ -61,35 +109,35 @@ export async function GET() {
},
},
take: 10,
})
const likeUsers = await Promise.all(
});
} else {
throw e;
}
}
const likes = await Promise.all(
likeRankings.map(async (item) => {
const user = await prisma.user.findUnique({
where: { id: item.userId },
select: { username: true },
})
});
return {
username: user?.username || '未知用户',
count: item._sum.likeCount || 0,
}
};
})
)
);
return NextResponse.json({
contributors: contributors.map((user) => ({
username: user.username,
count: user._count.materials,
})),
downloads: downloadUsers,
likes: likeUsers,
})
downloads,
likes,
});
} catch (error) {
console.error('Error fetching rankings:', error)
return NextResponse.json(
{ error: '获取排行榜失败' },
{ status: 500 }
)
console.error('Error fetching rankings:', error);
return NextResponse.json({ error: '获取排行榜失败' }, { status: 500 });
}
}

View File

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

View File

@ -1,208 +1,182 @@
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';
// 获取所有场景类别
export async function GET(req: NextRequest) {
// GET - 获取所有场景类别
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const categoryName = searchParams.get('category')
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const scenes = await prisma.scene.findMany({
orderBy: {
name: 'asc',
},
})
orderBy: { name: 'asc' },
});
// 获取每个场景类别下的素材数量
// 如果指定了店铺分类,只统计该店铺下的场景数量
// 统计每个场景的素材数量(只统计未删除的素材)
const scenesWithCount = await Promise.all(
scenes.map(async (scene) => {
const whereCondition: any = {
sceneName: scene.name,
}
// 如果选择了店铺,只统计该店铺下的场景数量
if (categoryName) {
whereCondition.categoryName = categoryName
const where: any = { sceneName: scene.name };
if (category) {
where.categoryName = category;
}
let count: number;
try {
where.deletedAt = null;
count = await prisma.material.count({ where } as any);
} catch (e: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (e?.meta?.column?.includes('deletedAt')) {
delete where.deletedAt;
count = await prisma.material.count({ where });
} else {
throw e;
}
}
const count = await prisma.material.count({
where: whereCondition,
})
return {
...scene,
count,
}
};
})
)
);
return NextResponse.json(scenesWithCount)
return NextResponse.json(scenesWithCount);
} catch (error) {
console.error('Error fetching scenes:', error)
console.error('Error fetching scenes:', error);
return NextResponse.json(
{ error: '获取场景类别列表失败' },
{ status: 500 }
)
);
}
}
// 添加新场景类别
export async function POST(req: NextRequest) {
// POST - 创建新的场景类别
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const { name } = await req.json()
const { name } = await request.json();
if (!name || !name.trim()) {
return NextResponse.json(
{ error: '场景类别名称不能为空' },
{ status: 400 }
)
);
}
// 检查场景类别是否已存在
// 检查是否已存在
const existing = await prisma.scene.findUnique({
where: { name: name.trim() },
})
});
if (existing) {
return NextResponse.json(
{ error: '该场景类别已存在' },
{ status: 400 }
)
);
}
// 创建新场景类别
const scene = await prisma.scene.create({
data: {
name: name.trim(),
},
})
data: { name: name.trim() },
});
return NextResponse.json(scene, { status: 201 })
return NextResponse.json(scene, { status: 201 });
} catch (error) {
console.error('Error creating scene:', error)
console.error('Error creating scene:', error);
return NextResponse.json(
{ error: '创建场景类别失败' },
{ status: 500 }
)
);
}
}
// 更新场景类别名称
export async function PATCH(req: NextRequest) {
// PATCH - 更新场景类别
export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '权限不足' },
{ status: 403 }
)
if (!session || session.user?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const { id, name } = await req.json()
const { id, name } = await request.json();
if (!id || !name || !name.trim()) {
return NextResponse.json(
{ error: '参数错误' },
{ status: 400 }
)
return NextResponse.json({ error: '参数错误' }, { status: 400 });
}
// 检查新名称是否已存在(排除当前场景)
// 检查新名称是否已被其他分类使用
const existing = await prisma.scene.findFirst({
where: {
name: name.trim(),
NOT: { id },
},
})
});
if (existing) {
return NextResponse.json(
{ error: '该场景类别已存在' },
{ status: 400 }
)
);
}
// 获取旧名称
const oldScene = await prisma.scene.findUnique({
where: { id },
})
});
if (!oldScene) {
return NextResponse.json(
{ error: '分类不存在' },
{ status: 404 }
)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
}
// 更新场景类别名称
const scene = await prisma.scene.update({
// 更新分类
const updated = await prisma.scene.update({
where: { id },
data: { name: name.trim() },
})
});
// 同步更新所有使用该场景的素材
// 更新所有使用该分类的素材
await prisma.material.updateMany({
where: { sceneName: oldScene.name },
data: { sceneName: name.trim() },
})
});
return NextResponse.json(scene)
return NextResponse.json(updated);
} catch (error) {
console.error('Error updating scene:', error)
return NextResponse.json(
{ error: '更新失败' },
{ status: 500 }
)
console.error('Error updating scene:', error);
return NextResponse.json({ error: '更新失败' }, { status: 500 });
}
}
// 删除场景类别
export async function DELETE(req: NextRequest) {
// DELETE - 删除场景类别
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json(
{ error: '权限不足' },
{ status: 403 }
)
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');
if (!id) {
return NextResponse.json(
{ error: '缺少ID参数' },
{ status: 400 }
)
return NextResponse.json({ error: '缺少ID参数' }, { status: 400 });
}
// 删除场景类别
await prisma.scene.delete({
where: { id },
})
});
return NextResponse.json({ success: true })
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting scene:', error)
return NextResponse.json(
{ error: '删除失败' },
{ status: 500 }
)
console.error('Error deleting scene:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
}
}

View File

@ -1,101 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 系统设置API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { proxyHttpUrl } from '@/lib/url-rewrite';
// GET - 获取系统设置
export async function GET() {
try {
// 获取或创建系统设置
let settings = await prisma.systemSettings.findFirst()
let settings = await prisma.systemSettings.findFirst();
// 如果没有设置,创建默认设置
if (!settings) {
// 如果不存在,创建默认设置
settings = await prisma.systemSettings.create({
data: {
headerBgColor: '#1f2937',
headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: '由壹点3D素材'
}
})
siteName: '由壹点3D素材',
},
});
}
return NextResponse.json(settings)
// 将 HTTP URL 转换为通过代理的 URL(解决混合内容问题)
const response = {
...settings,
headerLogoUrl: proxyHttpUrl(settings.headerLogoUrl) || settings.headerLogoUrl,
loginLogoUrl: proxyHttpUrl(settings.loginLogoUrl) || settings.loginLogoUrl,
};
return NextResponse.json(response);
} catch (error) {
console.error('Get system settings error:', error)
console.error('Get system settings error:', error);
return NextResponse.json(
{ error: '获取系统设置失败' },
{ status: 500 }
)
);
}
}
// PUT - 更新系统设置(仅管理员)
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
);
}
const body = await request.json()
const {
headerBgColor,
siteName,
headerLogoUrl,
const {
headerBgColor,
siteName,
headerLogoUrl,
loginLogoUrl,
s3Endpoint,
s3AccessKey,
s3SecretKey,
s3Bucket,
s3Region,
s3PublicUrl
} = body
s3PublicUrl,
} = await request.json();
// 获取或创建系统设置
let settings = await prisma.systemSettings.findFirst()
let settings = await prisma.systemSettings.findFirst();
if (!settings) {
// 创建
if (settings) {
// 更新现有设置
const updateData: any = {};
if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor;
if (siteName !== undefined) updateData.siteName = siteName;
if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl;
if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl;
if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint;
if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey;
if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey;
if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket;
if (s3Region !== undefined) updateData.s3Region = s3Region;
if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl;
settings = await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData,
});
} else {
// 创建新设置
settings = await prisma.systemSettings.create({
data: {
headerBgColor: headerBgColor || '#1f2937',
headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png',
siteName: siteName || '由壹点3D素材'
}
})
} else {
// 更新
const updateData: any = {}
if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor
if (siteName !== undefined) updateData.siteName = siteName
if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl
if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl
if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint
if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey
if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey
if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket
if (s3Region !== undefined) updateData.s3Region = s3Region
if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl
settings = await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData
})
siteName: siteName || '由壹点3D素材',
},
});
}
return NextResponse.json(settings)
return NextResponse.json(settings);
} catch (error) {
console.error('Update system settings error:', error)
console.error('Update system settings error:', error);
return NextResponse.json(
{ error: '更新系统设置失败' },
{ status: 500 }
)
);
}
}

View File

@ -1,90 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { uploadToS3 } from '@/lib/s3'
// 上传系统Logo API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
// POST - 上传LOGO(仅管理员)
// POST - 上传Logo(仅管理员)
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'ADMIN') {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
)
);
}
// 将NextRequest转换为Node.js IncomingMessage
const formData = await request.formData()
const logoFile = formData.get('logo') as File
const logoType = formData.get('type') as string // 'header' or 'login'
const formData = await request.formData();
const logo = formData.get('logo') as File;
const type = formData.get('type') as string;
if (!logoFile) {
if (!logo) {
return NextResponse.json(
{ error: '请选择LOGO文件' },
{ status: 400 }
)
);
}
if (!logoType || !['header', 'login'].includes(logoType)) {
if (!type || !['header', 'login'].includes(type)) {
return NextResponse.json(
{ error: '请指定LOGO类型(header或login)' },
{ status: 400 }
)
);
}
// 验证文件类型
if (!logoFile.type.startsWith('image/')) {
if (!logo.type.startsWith('image/')) {
return NextResponse.json(
{ error: '只支持图片格式' },
{ status: 400 }
)
);
}
// 转换File为Buffer
const bytes = await logoFile.arrayBuffer()
const buffer = Buffer.from(bytes)
// 转换为Buffer
const arrayBuffer = await logo.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 生成文件名
const timestamp = Date.now()
const ext = logoFile.name.split('.').pop()
const filename = `${logoType}-logo-${timestamp}.${ext}`
const timestamp = Date.now();
const ext = logo.name.split('.').pop();
const filename = `${type}-logo-${timestamp}.${ext}`;
// 上传到S3
const s3Url = await uploadToS3(buffer, filename, logoFile.type)
const logoUrl = await uploadToS3(buffer, filename, logo.type);
// 更新系统设置
let settings = await prisma.systemSettings.findFirst()
// 更新数据库
let settings = await prisma.systemSettings.findFirst();
const fieldName = type === 'header' ? 'headerLogoUrl' : 'loginLogoUrl';
const fieldName = logoType === 'header' ? 'headerLogoUrl' : 'loginLogoUrl'
if (!settings) {
settings = await prisma.systemSettings.create({
data: {
[fieldName]: s3Url
}
})
} else {
if (settings) {
settings = await prisma.systemSettings.update({
where: { id: settings.id },
data: {
[fieldName]: s3Url
}
})
data: { [fieldName]: logoUrl },
});
} else {
settings = await prisma.systemSettings.create({
data: { [fieldName]: logoUrl },
});
}
return NextResponse.json({
success: true,
logoUrl: s3Url,
type: logoType
})
logoUrl,
type,
});
} catch (error) {
console.error('Upload logo error:', error)
console.error('Upload logo error:', error);
return NextResponse.json(
{ error: '上传LOGO失败' },
{ status: 500 }
)
);
}
}

View File

@ -0,0 +1,50 @@
// 上传预览图API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { uploadToS3 } from '@/lib/s3';
// POST - 上传预览图
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: '请选择文件' },
{ status: 400 }
);
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
return NextResponse.json(
{ error: '只能上传图片文件' },
{ status: 400 }
);
}
// 转换为Buffer
const buffer = Buffer.from(await file.arrayBuffer());
// 上传到S3
const url = await uploadToS3(buffer, file.name, file.type);
return NextResponse.json({ url });
} catch (error) {
console.error('上传预览图失败:', error);
return NextResponse.json(
{ error: '上传失败,请稍后重试' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,100 @@
// 文件访问API - 提供本地上传文件的访问
import { NextRequest } from 'next/server';
import fs from 'fs';
import path from 'path';
import { lookup as mimeTypeLookup } from 'mime-types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// GET - 获取文件
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const pathSegments = params.path || [];
// 健康检查
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response('OK', { status: 200 });
}
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads';
const relativePath = path.join(...pathSegments);
const filePath = path.resolve(uploadDir, relativePath);
const baseDir = path.resolve(uploadDir);
// 安全检查:防止路径遍历攻击
if (!filePath.startsWith(baseDir)) {
return new Response('Forbidden', { status: 403 });
}
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile()) {
return new Response('Not Found', { status: 404 });
}
const range = request.headers.get('range');
const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream';
// 支持Range请求(用于视频流)
if (range) {
const fileSize = stats.size;
const parts = range.match(/bytes=(\d*)-(\d*)/);
const start = parts && parts[1] ? parseInt(parts[1], 10) : 0;
const end = parts && parts[2] ? parseInt(parts[2], 10) : fileSize - 1;
// 验证Range
if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) {
return new Response('Invalid Range', { status: 416 });
}
const stream = fs.createReadStream(filePath, { start, end });
return new Response(stream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}
// 普通请求
const stream = fs.createReadStream(filePath);
return new Response(stream as any, {
status: 200,
headers: {
'Content-Length': String(stats.size),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
return new Response('Server Error', { status: 500 });
}
}
// HEAD - 检查文件是否存在
export async function HEAD(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const pathSegments = params.path || [];
// 健康检查
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response(null, { status: 200 });
}
return new Response(null, { status: 404 });
}

View File

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { normalizeUrl } from '@/lib/utils';
import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import path from 'path';
// POST /api/user/avatar - 上传用户头像
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
console.log('[UserAvatar] Session:', { userId, hasSession: !!session, hasUser: !!session?.user });
if (!userId) {
console.log('[UserAvatar] No user ID in session');
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const formData = await request.formData();
const avatar = formData.get('avatar') as File | null;
if (!avatar) {
console.log('[UserAvatar] No avatar file provided');
return NextResponse.json({ error: '请选择头像文件' }, { status: 400 });
}
console.log('[UserAvatar] File info:', { name: avatar.name, type: avatar.type, size: avatar.size });
// 验证文件类型
if (!['image/jpeg', 'image/jpg', 'image/png', 'image/webp'].includes(avatar.type)) {
return NextResponse.json(
{ error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' },
{ status: 400 }
);
}
// 验证文件大小(5MB)
if (avatar.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: `头像过大,请使用小于5MB的图片(当前:${(avatar.size / 1024 / 1024).toFixed(2)}MB)` },
{ status: 400 }
);
}
// 压缩图片
const imageBuffer = Buffer.from(await avatar.arrayBuffer());
const compressedBuffer = await compressImage(imageBuffer, {
maxSizeKB: 100,
maxWidth: 200,
maxHeight: 200,
format: 'webp',
});
// 上传到S3
const sanitizedName = sanitizeFileName(avatar.name);
const ext = path.extname(sanitizedName) || '.webp';
const fileName = `avatar-${userId}-${Date.now()}${ext}`;
console.log('[UserAvatar] Uploading to S3:', fileName, `(${(compressedBuffer.length / 1024).toFixed(2)}KB)`);
const avatarUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp');
console.log('[UserAvatar] S3 URL:', avatarUrl);
// 更新用户头像
console.log('[UserAvatar] Updating user:', userId);
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { avatar: avatarUrl },
});
console.log('[UserAvatar] User updated:', { id: updatedUser.id, avatar: updatedUser.avatar });
// 规范化URL
const normalizedUrl = normalizeUrl(avatarUrl);
return NextResponse.json({ avatarUrl: normalizedUrl });
} catch (error) {
console.error('[UserAvatar] POST error:', error);
return NextResponse.json({ error: '上传头像失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { normalizeMaterial } from '@/lib/utils';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 解析 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('[user/favorites] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
// GET - 获取当前用户的收藏列表
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
// 查询收藏记录,包含素材信息
const favorites = await prisma.materialFavorite.findMany({
where: {
userId,
},
include: {
material: {
select: {
id: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
userId: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
deletedAt: true,
} as any,
},
},
orderBy: {
createdAt: 'desc',
},
});
// 提取素材,过滤已删除的
let materials = favorites
.map((fav) => fav.material)
.filter((material) => !!material && !material.deletedAt)
.map((material: any) => {
const zipFileSize = typeof material.zipFileSize === 'bigint'
? material.zipFileSize.toString()
: material.zipFileSize ?? null;
const formatted = {
...material,
zipFileSize,
displayImages: parseDisplayImages(material.displayImages || null),
isFavorited: true,
isLiked: false,
};
return normalizeMaterial(formatted);
});
// 批量查询点赞状态
if (materials.length > 0) {
const materialIds = materials.map((m) => m.id);
const likes = await prisma.materialLike.findMany({
where: {
userId,
materialId: { in: materialIds },
},
select: { materialId: true },
});
const likedSet = new Set(likes.map((like) => like.materialId));
materials.forEach((material) => {
material.isLiked = likedSet.has(material.id);
});
}
return NextResponse.json(materials);
} catch (error: any) {
// 兼容旧版本数据库(没有material_favorites表)
if (error?.meta?.table?.includes('material_favorites')) {
return NextResponse.json(
{ error: '收藏功能需要先运行数据库迁移' },
{ status: 400 }
);
}
console.error('[UserFavorites] GET error:', error);
return NextResponse.json(
{ error: '获取收藏失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { normalizeMaterial } from '@/lib/utils';
// 解析 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('[user/materials] Failed to parse displayImages JSON, fallback to single value:', e);
}
return displayImages.trim() ? [displayImages] : [];
}
return [];
}
// GET - 获取当前用户上传的素材
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
const materials = await prisma.material.findMany({
where: {
userId,
deletedAt: null,
},
select: {
id: true,
userId: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
} as any,
orderBy: {
createdAt: 'desc',
},
});
const formattedMaterials = materials.map((material: any) => {
const zipFileSize = typeof material.zipFileSize === 'bigint'
? material.zipFileSize.toString()
: material.zipFileSize ?? null;
const formatted = {
...material,
zipFileSize,
displayImages: parseDisplayImages(material.displayImages || null),
};
return normalizeMaterial(formatted);
});
return NextResponse.json(formattedMaterials);
} catch (error: any) {
// 兼容旧版本数据库(没有deletedAt字段)
if (error?.meta?.column?.includes('deletedAt')) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const userId = session.user.id;
const materials = await prisma.material.findMany({
where: {
userId,
},
select: {
id: true,
userId: true,
productModel: true,
previewUrl: true,
displayImages: true,
materialType: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
renderer: true,
downloadCount: true,
likeCount: true,
createdAt: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
} as any,
orderBy: {
createdAt: 'desc',
},
});
const formattedMaterials = materials.map((material: any) => {
const zipFileSize = typeof material.zipFileSize === 'bigint'
? material.zipFileSize.toString()
: material.zipFileSize ?? null;
const formatted = {
...material,
zipFileSize,
displayImages: Array.isArray(material.displayImages)
? material.displayImages
: material.displayImages
? [material.displayImages]
: [],
};
return normalizeMaterial(formatted);
});
return NextResponse.json(formattedMaterials);
} catch (fallbackError) {
console.error('[UserMaterials] Fallback query error:', fallbackError);
return NextResponse.json(
{ error: '获取素材列表失败' },
{ status: 500 }
);
}
}
console.error('[UserMaterials] GET error:', error);
return NextResponse.json(
{ error: '获取素材列表失败' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
// GET - 获取当前用户信息
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
avatar: true,
department: true,
realName: true,
phone: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
return NextResponse.json(user);
} catch (error) {
console.error('[UserProfile] GET error:', error);
return NextResponse.json(
{ error: '获取用户信息失败' },
{ status: 500 }
);
}
}

View File

@ -54,7 +54,7 @@ function CompleteProfileContent() {
}
// 从URL参数获取用户信息
const userInfoParam = searchParams.get('userInfo')
const userInfoParam = searchParams?.get('userInfo')
if (userInfoParam) {
try {
const info = JSON.parse(decodeURIComponent(userInfoParam))

View File

@ -13,8 +13,8 @@ function DingtalkCallbackContent() {
useEffect(() => {
const handleDingtalkLogin = async () => {
try {
const code = searchParams.get('code')
const state = searchParams.get('state') || 'default'
const code = searchParams?.get('code')
const state = searchParams?.get('state') || 'default'
if (!code) {
throw new Error('授权失败:缺少授权码')

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName } from '@/lib/material-validation'
interface Material {
id: string
@ -11,6 +12,8 @@ interface Material {
productModel: string
categoryName: string
sceneName: string
platformName?: string | null
productCategoryName?: string | null
materialType: string
renderer: string
authorName: string
@ -28,15 +31,36 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [productModel, setProductModel] = useState('')
const [categoryName, setCategoryName] = useState('')
const [sceneName, setSceneName] = useState('')
const [platformName, setPlatformName] = useState('')
const [productCategoryName, setProductCategoryName] = useState('')
const [materialType, setMaterialType] = useState('')
const [renderer, setRenderer] = useState('')
const [authorName, setAuthorName] = useState('')
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
const materialTypes = [
'3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '材质', '模特', '标准饰品', '其他'
'3D场景',
'详情页',
'材质',
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'主图视频',
'安装视频',
'种草视频',
'音乐',
'C4D模型',
'标准饰品',
'标准模型',
'品牌VI灵感',
'矢量文件'
]
useEffect(() => {
@ -49,6 +73,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
fetchMaterial()
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchProductCategories()
}
}, [status, params.id])
@ -71,6 +97,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setProductModel(data.productModel || '')
setCategoryName(data.categoryName || '')
setSceneName(data.sceneName || '')
setPlatformName(data.platformName || '')
setProductCategoryName(data.productCategoryName || '')
setMaterialType(data.materialType || '')
setRenderer(data.renderer || '')
setAuthorName(data.authorName || '')
@ -106,15 +134,56 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
}
}
const fetchPlatforms = async () => {
try {
const response = await fetch('/api/platforms')
if (response.ok) {
const data = await response.json()
setPlatforms(data)
}
} catch (error) {
console.error('获取平台分类失败:', error)
}
}
const fetchProductCategories = async () => {
try {
const response = await fetch('/api/product-categories')
if (response.ok) {
const data = await response.json()
setProductCategories(data)
}
} catch (error) {
console.error('获取品类分类失败:', error)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
// 基础必填字段验证
if (!productModel || !materialType || !authorName) {
setError('请填写所有必填字段')
return
}
// 使用统一的验证逻辑
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
if (requiresProductCategoryName(materialType) && !productCategoryName) {
setError('标准模型类型必须选择品类')
return
}
if (requiresRenderer(materialType) && !renderer) {
setError('3D场景类型必须指定渲染器')
return
}
setSubmitting(true)
@ -126,6 +195,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
productModel,
categoryName,
sceneName,
platformName: platformName || null,
productCategoryName: productCategoryName || null,
materialType,
renderer,
authorName
@ -228,6 +299,38 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="input-apple"
>
<option value="">不选择</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类分类(可选)
</label>
<select
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
className="input-apple"
>
<option value="">不选择</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材类型 *

View File

@ -53,3 +53,19 @@ body {
@apply bg-white dark:bg-gray-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-gray-700;
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}

View File

@ -24,6 +24,11 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: settings?.siteName || '由壹点3D素材',
description: '3D素材分享平台',
icons: {
icon: '/favicon.ico',
shortcut: '/favicon.ico',
apple: '/favicon.ico',
},
}
}

File diff suppressed because it is too large Load Diff

556
app/profile/page.tsx Normal file
View File

@ -0,0 +1,556 @@
/**
* 个人中心页面
*
* 参考:flnxi_sucai/app/profile/page.tsx
* 包含"我的素材"和"我的收藏"两个标签页
*/
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import Header from '@/components/Header'
import VideoPlayerModal from '@/components/VideoPlayerModal'
import { useToast } from '@/hooks/useToast'
import ToastBubble from '@/components/ToastBubble'
interface UserProfile {
id: string
username: string
email: string
avatar: string | null
department?: string
realName?: string | null
createdAt?: string
}
interface Material {
id: string
userId: string
productModel: string
previewUrl: string
displayImages?: string[]
materialType?: string
categoryName?: string
sceneName?: string
renderer?: string
authorName?: string
downloadCount: number
likeCount: number
createdAt: string
isFavorited?: boolean
isLiked?: boolean
zipFileName?: string
zipFileSize?: string | number
videoCoverUrl?: string | null
videoPreview540Url?: string | null
videoPreviewClipUrl?: string | null
videoSourceUrl?: string | null
videoProcessingStatus?: string | null
videoDuration?: number | null
}
export default function ProfilePage() {
const { data: session, status, update } = useSession()
const router = useRouter()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [materials, setMaterials] = useState<Material[]>([])
const [favorites, setFavorites] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null)
const [showVideoModal, setShowVideoModal] = useState(false)
const [videoSrc, setVideoSrc] = useState<string | null>(null)
const [showAvatarModal, setShowAvatarModal] = useState(false)
const { toast, showToast } = useToast()
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarPreview, setAvatarPreview] = useState<string>('')
const [uploading, setUploading] = useState(false)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated' && session) {
fetchProfile()
fetchMaterials()
fetchFavorites()
}
}, [status, session, router])
const fetchProfile = async () => {
try {
const res = await fetch('/api/user/profile')
if (res.ok) {
const data = await res.json()
setProfile(data)
}
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}
const fetchMaterials = async () => {
try {
const res = await fetch('/api/user/materials')
if (res.ok) {
const data = await res.json()
setMaterials(data)
}
} catch (error) {
console.error('Failed to fetch materials:', error)
} finally {
setLoading(false)
}
}
const fetchFavorites = async () => {
try {
const res = await fetch('/api/user/favorites')
if (res.ok) {
const data = await res.json()
setFavorites(data)
}
} catch (error) {
console.error('Failed to fetch favorites:', error)
}
}
// 判断是否为视频素材
const isVideoMaterial = (materialType?: string) => {
return materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频'
}
// 格式化时长
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 获取素材类型标签样式
const getMaterialTypeBadgeClass = (materialType?: string) => {
const typeMap: Record<string, string> = {
'3D场景': 'bg-blue-500',
'详情页': 'bg-purple-500',
'矢量文件': 'bg-green-500',
'C4D模型': 'bg-orange-500',
'视频': 'bg-red-500',
'主图视频': 'bg-red-500',
'安装视频': 'bg-red-500',
'种草视频': 'bg-red-500',
'模特': 'bg-pink-500',
'主图模版': 'bg-indigo-500',
'SKU模版': 'bg-teal-500',
'音乐': 'bg-yellow-500',
'标准饰品': 'bg-cyan-500',
'品牌VI灵感': 'bg-rose-500',
'CAD': 'bg-gray-600',
'标准模型': 'bg-violet-500',
}
return typeMap[materialType || ''] || 'bg-gray-500'
}
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', { variant: 'error' })
return
}
setAvatarFile(file)
setAvatarPreview(URL.createObjectURL(file))
setShowAvatarModal(true)
}
}
const handleAvatarUpload = async () => {
if (!avatarFile) return
setUploading(true)
try {
const formData = new FormData()
formData.append('avatar', avatarFile)
const res = await fetch('/api/user/avatar', {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
console.log('[Profile] Avatar uploaded successfully:', data.avatarUrl)
// 关键:立即更新本地状态,显示新头像
setProfile(prev => prev ? { ...prev, avatar: data.avatarUrl } : null)
setShowAvatarModal(false)
setAvatarFile(null)
setAvatarPreview('')
// 立即更新 NextAuth 会话,触发 Header 中头像同步更新
await update({ avatar: data.avatarUrl })
} else {
const error = await res.json()
showToast(error.error || '上传失败', { variant: 'error' })
}
} catch (error) {
console.error('Failed to upload avatar:', error)
showToast('上传失败,请稍后重试', { variant: 'error' })
} finally {
setUploading(false)
}
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<p className="text-gray-500 dark:text-gray-400">加载中...</p>
</div>
)
}
if (!session) {
return null
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 用户信息卡片 */}
<div className="card-apple mb-6 p-6 dark:bg-gray-800 dark:border-gray-700">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
{/* 头像 */}
<div className="relative">
{profile?.avatar ? (
<div className="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
<Image
src={profile.avatar}
alt="头像"
width={80}
height={80}
className="w-full h-full object-cover"
unoptimized
/>
</div>
) : (
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white text-2xl font-bold shadow-lg">
{(profile?.username || session.user?.name || session.user?.email || 'U').charAt(0).toUpperCase()}
</div>
)}
<label className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors shadow-lg">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<input
type="file"
accept="image/*"
onChange={handleAvatarSelect}
className="hidden"
/>
</label>
</div>
{/* 用户信息 */}
<div className="flex-1 text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{profile?.username || session.user?.name || session.user?.email || '用户'}
</h1>
<div className="text-sm text-gray-500 dark:text-gray-400 space-y-1 mb-4">
<p>@{(profile?.email || session.user?.email || 'user').split('@')[0]}</p>
<p>{profile?.email || session.user?.email}</p>
{profile?.department ? (
<p>部门:{profile.department}</p>
) : null}
{profile?.createdAt ? (
<p>注册时间:{new Date(profile.createdAt as any).toLocaleDateString()}</p>
) : null}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">我的素材:</span>
<span className="text-gray-700 dark:text-gray-300 font-semibold">{materials.length}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">我的收藏:</span>
<span className="text-gray-700 dark:text-gray-300 font-semibold">{favorites.length}</span>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* 我的素材卡片(上) */}
<div className="card-apple dark:bg-gray-800 dark:border-gray-700">
<div className="flex items-center justify-between px-4 pt-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">我的素材</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">已上传 {materials.length} 个</p>
</div>
</div>
{materials.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">还没有上传过素材</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
{materials.map((material) => {
const isVideo = isVideoMaterial(material.materialType)
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
const poster = material.videoCoverUrl || material.previewUrl
return (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-shadow"
onClick={() => {
// 所有素材类型统一跳转到详情页面
router.push(`/share/${material.id}`)
}}
>
{/* 预览图/视频 */}
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
{isVideo && clipUrl ? (
<video
src={clipUrl}
poster={poster || undefined}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
muted
loop
playsInline
preload="metadata"
/>
) : material.previewUrl ? (
<Image
src={material.previewUrl}
alt={material.productModel}
fill
unoptimized
className="object-cover transition-transform group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 300px"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600">
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
</div>
)}
{/* SPU标签 */}
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{material.productModel}
</div>
{/* 视频时长角标 */}
{isVideo && material.videoDuration && (
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{formatDuration(material.videoDuration)}
</div>
)}
</div>
{/* 素材信息 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-sm font-medium text-white truncate mb-1">
{material.productModel}
</p>
<div className="flex items-center justify-between text-xs text-white/80">
<span>下载 {material.downloadCount}</span>
<span>点赞 {material.likeCount}</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* 我的收藏卡片(下) */}
<div className="card-apple dark:bg-gray-800 dark:border-gray-700">
<div className="flex items-center justify-between px-4 pt-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">我的收藏</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">已收藏 {favorites.length} 个</p>
</div>
</div>
{favorites.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">还没有收藏素材</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
{favorites.map((material) => {
const isVideo = isVideoMaterial(material.materialType)
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
const poster = material.videoCoverUrl || material.previewUrl
return (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-shadow"
onClick={() => {
// 所有素材类型统一跳转到详情页面
router.push(`/share/${material.id}`)
}}
>
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
{isVideo && clipUrl ? (
<video
src={clipUrl}
poster={poster || undefined}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
muted
loop
playsInline
preload="metadata"
/>
) : material.previewUrl ? (
<Image
src={material.previewUrl}
alt={material.productModel}
fill
unoptimized
className="object-cover transition-transform group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 300px"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600">
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
</div>
)}
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{material.productModel}
</div>
{isVideo && material.videoDuration && (
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{formatDuration(material.videoDuration)}
</div>
)}
{/* 收藏角标 */}
<div className="absolute bottom-2 right-2 bg-red-500 text-white p-1.5 rounded-full">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<p className="text-sm font-medium text-white truncate mb-1">
{material.productModel}
</p>
<div className="flex items-center justify-between text-xs text-white/80">
<span>下载 {material.downloadCount}</span>
<span>点赞 {material.likeCount}</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
{/* 图片预览模态框 */}
{previewImage && (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
<Image
src={previewImage.url}
alt={previewImage.title}
fill
unoptimized
className="object-contain"
sizes="90vw"
/>
<button
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 text-white bg-black/50 hover:bg-black/70 rounded-full p-2 transition-all"
>
<svg className="w-6 h-6" 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>
)}
{/* Video Player Modal(参考首页实现) */}
{showVideoModal && videoSrc && (
<VideoPlayerModal
videoSrc={videoSrc}
onClose={() => {
setShowVideoModal(false)
setVideoSrc(null)
}}
/>
)}
{/* 头像上传模态框 */}
{showAvatarModal && (
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4" onClick={() => setShowAvatarModal(false)}>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">上传头像</h3>
{avatarPreview && (
<div className="mb-4 flex justify-center">
<div className="w-32 h-32 rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
<Image
src={avatarPreview}
alt="预览"
width={128}
height={128}
className="w-full h-full object-cover"
unoptimized
/>
</div>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => {
setShowAvatarModal(false)
setAvatarFile(null)
setAvatarPreview('')
}}
className="flex-1 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
取消
</button>
<button
onClick={handleAvatarUpload}
disabled={uploading}
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{uploading ? '上传中...' : '确认上传'}
</button>
</div>
</div>
</div>
)}
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -1,18 +1,27 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } from 'next/navigation'
import Image from 'next/image'
import { format } from 'date-fns'
import Header from '@/components/Header'
import VideoPlayerModal from '@/components/VideoPlayerModal'
import SkuList from '@/components/SkuList'
import SkuEditModal from '@/components/SkuEditModal'
import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import AudioVisualizer from '@/components/AudioVisualizer'
interface Material {
id: string
userId: string
previewUrl: string
productModel: string
categoryName: string
sceneName: string
platformName?: string // 平台分类
productCategoryName?: string // 品类分类
materialType: string
authorName: string
zipFileName: string
@ -21,19 +30,42 @@ interface Material {
likeCount: number
displayImages: string[] // PSD展示图片URLs
createdAt: string
videoSourceUrl?: string | null // 原始视频URL
videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库)
videoPreviewClipUrl?: string | null // 视频片段URL(10秒,无音频)
videoCoverUrl?: string | null // 视频封面URL
videoDuration?: number | null // 视频时长(秒)
videoProcessingStatus?: string | null // 视频处理状态
isLiked?: boolean // 是否已点赞
isFavorited?: boolean // 是否已收藏
skus?: Array<{
id: string
skuName: string
filePath: string
fileSize: string
sortOrder: number
}>
}
export default function SharePage() {
const { data: session, status } = useSession()
const router = useRouter()
const params = useParams()
const materialId = params.id as string
const materialId = params?.id as string
const [material, setMaterial] = useState<Material | null>(null)
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [showBackToTop, setShowBackToTop] = useState(false)
const [showVideoModal, setShowVideoModal] = useState(false)
const [videoSrc, setVideoSrc] = useState<string | null>(null)
const [showSkuEditModal, setShowSkuEditModal] = useState(false)
const { toast, showToast } = useToast() // 统一的Toast提示
// 音频播放状态
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingAudio, setPlayingAudio] = useState(false)
const [audioProgress, setAudioProgress] = useState(0) // 0-100
useEffect(() => {
// 分享页面允许未登录访问
@ -57,13 +89,22 @@ export default function SharePage() {
if (response.ok) {
const data = await response.json()
setMaterial(data)
// 如果是标准模型类型,获取SKU列表
if (data.materialType === '标准模型') {
const skuResponse = await fetch(`/api/materials/${materialId}/skus`)
if (skuResponse.ok) {
const skuData = await skuResponse.json()
setMaterial(prev => prev ? { ...prev, skus: skuData.skus } : null)
}
}
} else {
alert('素材不存在或已被删除')
showToast('素材不存在或已被删除', { variant: 'error' })
router.push('/')
}
} catch (error) {
console.error('Error fetching material:', error)
alert('加载失败')
showToast('加载失败', { variant: 'error' })
router.push('/')
} finally {
setLoading(false)
@ -82,6 +123,129 @@ export default function SharePage() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 音频播放控制
const handleAudioPlay = useCallback(() => {
if (!material || material.materialType !== '音乐') {
console.warn('[Share] handleAudioPlay: material is not music type', material?.materialType)
return
}
// 如果正在播放,则暂停
if (playingAudio) {
if (audioRef.current) {
audioRef.current.pause()
}
setPlayingAudio(false)
setAudioProgress(0)
return
}
// 如果音频已经加载,直接播放
if (audioRef.current && audioRef.current.readyState >= 2) {
audioRef.current.play().then(() => {
setPlayingAudio(true)
}).catch((error) => {
console.error('[Share] Audio play failed:', error)
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
})
return
}
// 停止之前的音频
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
// 创建新的音频元素
// 如果zipFileName是HTTP URL(老素材),直接使用;否则加上/uploads/前缀(新素材)
let audioUrl = ''
if (material.zipFileName) {
const zipFileName = material.zipFileName
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
// 老素材:zipFileName是完整的S3 URL
audioUrl = zipFileName
} else {
// 新素材:zipFileName只是文件名,需要加上/uploads/前缀
audioUrl = `/uploads/${zipFileName}`
}
}
console.log('[Share] Audio URL:', audioUrl, 'Material:', { id: material.id, zipFileName: material.zipFileName })
if (!audioUrl) {
console.error('[Share] No audio URL found for material:', material.id)
showToast('音频文件不存在', { variant: 'error' })
return
}
const audio = new Audio(audioUrl)
audio.preload = 'auto' // 预加载音频
audioRef.current = audio
// 监听加载错误
audio.addEventListener('error', (e) => {
const error = audio.error
console.error('[Share] Audio error:', {
code: error?.code,
message: error?.message,
audioUrl,
materialId: material.id
})
showToast(`音频加载失败: ${error?.message || '未知错误'}`, { variant: 'error' })
setPlayingAudio(false)
setAudioProgress(0)
audioRef.current = null
})
// 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟)
const handleCanPlay = () => {
console.log('[Share] Audio can play:', audioUrl)
audio.play().then(() => {
console.log('[Share] Audio playback started:', audioUrl)
setPlayingAudio(true)
}).catch((error) => {
console.error('[Share] Audio play failed:', error, 'URL:', audioUrl)
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
setPlayingAudio(false)
audioRef.current = null
})
audio.removeEventListener('canplay', handleCanPlay)
}
audio.addEventListener('canplay', handleCanPlay)
// 如果音频已经可以播放,立即播放
if (audio.readyState >= 2) {
handleCanPlay()
}
// 监听播放进度
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
setAudioProgress((audio.currentTime / audio.duration) * 100)
}
})
// 监听播放结束
audio.addEventListener('ended', () => {
setPlayingAudio(false)
setAudioProgress(0)
})
// 开始加载音频
audio.load()
}, [material, playingAudio, showToast])
// 组件卸载时清理音频
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
}
}, [])
const handleDownload = () => {
if (!material) return
@ -121,17 +285,17 @@ export default function SharePage() {
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载处理失败:', error)
alert('下载失败,请稍后重试')
showToast('下载失败,请稍后重试', { variant: 'error' })
}
} else {
alert('下载失败')
showToast('下载失败', { variant: 'error' })
}
setDownloading(false)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
alert('下载失败,请稍后重试')
showToast('下载失败,请稍后重试', { variant: 'error' })
setDownloading(false)
setDownloadProgress(0)
})
@ -170,86 +334,261 @@ export default function SharePage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<main className="container mx-auto px-4 py-8">
{(material.materialType === 'PSD' || material.materialType === '模特') && material.displayImages && material.displayImages.length > 0 ? (
/* PSD/模特类型:左右布局 */
<div className="flex gap-8 max-w-7xl mx-auto">
{/* 左侧:展示图片(固定790px宽度) */}
<div className="flex-shrink-0" style={{ width: '790px' }}>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
{material.displayImages.map((imageUrl, index) => (
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
{/* 所有素材类型统一使用左右布局 */}
<div className="flex gap-8 max-w-7xl mx-auto">
{/* 左侧:预览图(固定790px宽度) */}
<div className="flex-shrink-0" style={{ width: '790px' }}>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
{(() => {
// 判断是否为视频类型
const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频'
const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url || material.videoSourceUrl
const posterUrl = material.videoCoverUrl || material.previewUrl
if ((material.materialType === '详情页' || material.materialType === '模特') && material.displayImages && material.displayImages.length > 0) {
/* 详情页/模特类型:显示多张展示图 */
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
{material.displayImages.map((imageUrl, index) => (
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
<Image
src={imageUrl}
alt={`${material.productModel} - 展示图 ${index + 1}`}
width={790}
height={1500}
unoptimized
className="w-full h-auto"
style={{ display: 'block', maxWidth: '790px' }}
/>
</div>
))}
</div>
)
} else if (isVideoType && videoUrl) {
/* 视频类型:显示视频预览(可点击播放完整视频) */
return (
<div
className="relative w-full bg-gray-200 dark:bg-gray-700 cursor-pointer group"
onClick={() => {
// 优先使用720p预览,其次使用10秒片段,最后使用源视频
const playUrl = material.videoPreview540Url || material.videoPreviewClipUrl || material.videoSourceUrl
if (playUrl) {
setVideoSrc(playUrl)
setShowVideoModal(true)
}
}}
>
<video
src={material.videoPreviewClipUrl || material.videoPreview540Url || undefined}
poster={posterUrl || undefined}
className="w-full h-auto object-contain"
muted
loop
playsInline
preload="metadata"
/>
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-all">
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{/* 视频时长角标 */}
{material.videoDuration && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
)
} else if (material.materialType === '音乐') {
/* 音频类型:显示音频播放器和可视化 */
return (
<div className="relative w-full bg-gradient-to-b from-slate-800 to-slate-900 flex flex-col items-center justify-center" style={{ minHeight: '500px' }}>
{/* 圆形频谱可视化背景 - 只在播放时显示 */}
{playingAudio && (
<div className="absolute inset-0 flex items-center justify-center">
<AudioVisualizer
audioElement={audioRef.current}
isPlaying={true}
size="lg"
coverImage={material.previewUrl || null}
showCoverOnPause={true}
/>
</div>
)}
{/* 播放进度条(底部) - 可点击和拖动 */}
{playingAudio && (
<div
className="absolute bottom-0 left-0 right-0 h-2 bg-slate-700 z-20 cursor-pointer hover:h-3 transition-all group"
onClick={(e) => {
e.stopPropagation()
if (audioRef.current) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
audioRef.current.currentTime = audioRef.current.duration * percent
}
}}
onMouseDown={(e) => {
e.stopPropagation()
const handleMouseMove = (moveEvent: MouseEvent) => {
if (audioRef.current) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width))
audioRef.current.currentTime = audioRef.current.duration * percent
}
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}}
>
<div
className="h-full bg-blue-500 transition-all duration-100 relative"
style={{ width: `${audioProgress}%` }}
>
{/* 拖动手柄 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
)}
{/* 信息覆盖层 */}
<div className="relative z-10 flex flex-col items-center justify-center p-8">
{/* 封面图(如果有,且未播放时显示) */}
{material.previewUrl && !playingAudio && (
<div className="mb-6">
<Image
src={material.previewUrl}
alt={material.productModel}
width={400}
height={400}
unoptimized
className="rounded-2xl shadow-xl"
/>
</div>
)}
{/* 播放/暂停按钮 */}
<button
type="button"
onClick={handleAudioPlay}
className={`w-20 h-20 rounded-full backdrop-blur-sm flex items-center justify-center border transition-all ${
playingAudio
? "bg-white/30 border-white/50 hover:bg-white/40"
: "bg-white/20 border-white/30 hover:scale-110 hover:bg-white/30"
}`}
>
{playingAudio ? (
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="w-10 h-10 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
{/* 音频名称 */}
<p className="text-white text-xl font-medium text-center px-4 mt-6 drop-shadow-lg">{material.productModel}</p>
</div>
</div>
)
} else {
/* 其他类型:显示单张预览图(自适应高度) */
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<Image
src={imageUrl}
alt={`${material.productModel} - 展示图 ${index + 1}`}
src={material.previewUrl}
alt={material.productModel}
width={790}
height={1500}
height={1000}
unoptimized
className="w-full h-auto"
className="w-full h-auto object-contain"
style={{ display: 'block', maxWidth: '790px' }}
/>
</div>
))}
</div>
</div>
)
}
})()}
</div>
{/* 右侧:素材信息(固定) */}
<div className="w-80 flex-shrink-0">
<div className="sticky top-24 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{material.productModel}
</h1>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium">
{material.categoryName}
</span>
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-medium">
{material.sceneName}
</span>
<span className="inline-flex items-center px-3 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium">
{/* 标准模型类型:显示SKU列表 */}
{material.materialType === '标准模型' && (
<div className="mt-8">
<SkuList
materialId={materialId}
canEdit={!!(session?.user && ((session.user as any)?.id === material.userId || (session.user as any)?.role === 'ADMIN'))}
onEditClick={() => setShowSkuEditModal(true)}
/>
</div>
)}
</div>
{/* 右侧:素材信息(固定) */}
<div className="w-80 flex-shrink-0">
<div className="sticky top-24 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 space-y-6">
{/* 标题和标签 - 统一间距 */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{material.productModel}
</h1>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
{material.categoryName}
</span>
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
{material.sceneName}
</span>
{material.materialType && (
<span className="inline-flex items-center px-3 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full text-sm font-medium">
{material.materialType}
</span>
)}
</div>
</div>
{/* 素材信息 - 统一字体和间距 */}
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.authorName}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
<p className="text-base font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">点赞数</p>
<p className="text-base font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">作者</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.authorName}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">下载</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">点赞</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
</div>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">文件名</p>
<p className="text-xs text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
<p className="text-xs text-gray-700 dark:text-gray-300">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
<p className="text-sm text-gray-700 dark:text-gray-300">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
</div>
{/* Download Button */}
{/* 操作按钮 - 统一样式 */}
<div className="space-y-3 pt-2">
<button
onClick={handleDownload}
disabled={downloading}
className="w-full btn-primary py-3 text-sm font-medium relative overflow-hidden disabled:opacity-75"
className="w-full btn-primary py-3 text-base font-medium relative overflow-hidden disabled:opacity-75"
>
{downloading ? (
<>
@ -265,7 +604,7 @@ export default function SharePage() {
</span>
</>
) : (
<span className="flex items-center justify-center gap-2">
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -274,117 +613,17 @@ export default function SharePage() {
)}
</button>
<button
onClick={() => router.push('/')}
className="w-full py-2.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
onClick={() => router.back()}
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-base font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
返回首页
返回上一页
</button>
</div>
</div>
</div>
) : (
/* 非PSD类型:原有布局 */
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
{/* 普通预览图 */}
<div className="relative w-full h-96 bg-gray-200 dark:bg-gray-700">
<Image
src={material.previewUrl}
alt={material.productModel}
fill
unoptimized
className="object-contain"
sizes="(max-width: 1024px) 100vw, 1024px"
/>
</div>
{/* Material Info */}
<div className="p-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{material.productModel}
</h1>
<div className="flex items-center gap-3 mb-4">
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
{material.categoryName}
</span>
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
{material.sceneName}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.authorName}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
<p className="text-lg font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">点赞数</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
</div>
</div>
<div className="mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
</div>
<div className="mb-6">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
<p className="text-sm text-gray-700 dark:text-gray-300">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
{/* Download Button */}
<button
onClick={handleDownload}
disabled={downloading}
className="w-full btn-primary py-4 text-lg font-medium relative overflow-hidden disabled:opacity-75"
>
{downloading ? (
<>
<div
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
<span className="relative z-10 flex items-center justify-center gap-2">
<svg className="w-6 h-6 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
下载中 {downloadProgress}%
</span>
</>
) : (
<span className="flex items-center justify-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
立即下载
</span>
)}
</button>
<button
onClick={() => router.push('/')}
className="w-full mt-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
返回首页
</button>
</div>
</div>
</div>
)}
</main>
{/* 返回顶部按钮 */}
@ -399,6 +638,45 @@ export default function SharePage() {
</svg>
</button>
)}
{/* Video Player Modal */}
{showVideoModal && videoSrc && (
<VideoPlayerModal
videoSrc={videoSrc}
onClose={() => {
setShowVideoModal(false)
setVideoSrc(null)
}}
/>
)}
{/* SKU编辑模态框 */}
{showSkuEditModal && material && (
<SkuEditModal
materialId={materialId}
isOpen={showSkuEditModal}
onClose={() => {
setShowSkuEditModal(false)
}}
onSaved={() => {
setShowSkuEditModal(false)
// 重新获取SKU列表
if (material.materialType === '标准模型') {
fetch(`/api/materials/${materialId}/skus`)
.then(res => res.json())
.then(data => {
setMaterial(prev => prev ? { ...prev, skus: data.skus } : null)
})
.catch(console.error)
}
}}
/>
)}
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -0,0 +1,74 @@
import { NextRequest } from 'next/server'
import { createReadStream, statSync } from 'fs'
import { promises as fsp } from 'fs'
import path from 'path'
import mime from 'mime-types'
// Fallback streaming for /uploads/* when NAS/static mapping is not configured.
// Supports Range requests for video playback.
export async function GET(req: NextRequest, context: { params: { path?: string[] } }) {
const segments = context.params.path || []
// Health probe: /uploads/health
if (segments.length === 1 && segments[0] === 'health') {
return new Response('OK', { status: 200 })
}
const uploadsRoot = process.env.UPLOAD_DIR || './uploads'
// Prevent path traversal
const safeRel = path.join(...segments)
const absPath = path.resolve(uploadsRoot, safeRel)
const rootResolved = path.resolve(uploadsRoot)
if (!absPath.startsWith(rootResolved)) {
return new Response('Forbidden', { status: 403 })
}
try {
const stat = await fsp.stat(absPath)
if (!stat.isFile()) {
return new Response('Not Found', { status: 404 })
}
const range = req.headers.get('range')
const contentType = (mime.contentType(path.extname(absPath)) as string) || 'application/octet-stream'
if (range) {
const size = stat.size
const match = range.match(/bytes=(\d*)-(\d*)/)
const start = match && match[1] ? parseInt(match[1], 10) : 0
const end = match && match[2] ? parseInt(match[2], 10) : size - 1
if (isNaN(start) || isNaN(end) || start > end || end >= size) {
return new Response('Invalid Range', { status: 416 })
}
const chunkSize = end - start + 1
const stream = createReadStream(absPath, { start, end })
return new Response(stream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
}
const stream = createReadStream(absPath)
return new Response(stream as any, {
status: 200,
headers: {
'Content-Length': stat.size.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
} catch (err: any) {
if (err.code === 'ENOENT') {
return new Response('Not Found', { status: 404 })
}
return new Response('Server Error', { status: 500 })
}
}

View File

@ -0,0 +1,436 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
// 简单的className合并函数
function cn(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(' ')
}
// 全局 WeakMap 跟踪所有 audio 元素的初始化状态(跨组件实例共享)
const globalAudioInitState = new WeakMap<HTMLAudioElement, {
source: MediaElementAudioSourceNode | null
analyser: AnalyserNode | null
initialized: boolean
failed: boolean
initializing: boolean
}>()
interface AudioVisualizerProps {
audioElement: HTMLAudioElement | null
isPlaying: boolean
size?: 'sm' | 'md' | 'lg'
coverImage?: string | null
showCoverOnPause?: boolean
className?: string
}
// 默认封面图组件 - Apple Music 风格
function DefaultAudioCover({ size }: { size: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-full h-full',
md: 'w-full h-full',
lg: 'w-full h-full',
}
const iconSizes = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
}
return (
<div
className={cn(
sizeClasses[size],
'relative overflow-hidden rounded-lg',
'bg-gradient-to-br from-[#1a1a2e] via-[#16213e] to-[#0f3460]'
)}
>
{/* 装饰光晕 */}
<div className="absolute top-1/4 left-1/4 w-1/2 h-1/2 bg-blue-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-1/3 h-1/3 bg-purple-500/20 rounded-full blur-2xl" />
{/* 中心音符图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="p-4 rounded-full bg-white/10 backdrop-blur-sm border border-white/20">
<svg
className={cn(iconSizes[size], 'text-white/80')}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
</svg>
</div>
</div>
</div>
)
}
// 圆形放射状频谱可视化器 - 尖刺风格
export default function AudioVisualizer({
audioElement,
isPlaying,
size = 'md',
coverImage,
showCoverOnPause = true,
className,
}: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null)
const connectedAudioRef = useRef<HTMLAudioElement | null>(null)
const [isVisualizerActive, setIsVisualizerActive] = useState(false)
const [showCover, setShowCover] = useState(true)
// 尺寸配置 - 增加频谱条数量以获得更密集的尖刺效果
const sizeConfig = {
sm: { width: 160, height: 160, bars: 64, innerRadius: 25, maxBarHeight: 35 },
md: { width: 280, height: 280, bars: 96, innerRadius: 45, maxBarHeight: 65 },
lg: { width: 400, height: 400, bars: 128, innerRadius: 70, maxBarHeight: 95 },
}
const config = sizeConfig[size]
// 使用全局 WeakMap 跟踪 audio 元素的初始化状态
// 这样可以确保所有 AudioVisualizer 实例共享同一个初始化状态
// 绘制尖刺放射状频谱
const draw = useCallback(() => {
const canvas = canvasRef.current
const analyser = analyserRef.current
if (!canvas || !analyser) return
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
analyser.getByteFrequencyData(dataArray)
const { width, height, bars, innerRadius, maxBarHeight } = config
const centerX = width / 2
const centerY = height / 2
// 清除画布
ctx.clearRect(0, 0, width, height)
// 绘制深色背景圆
const bgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius + maxBarHeight + 20)
bgGradient.addColorStop(0, 'rgba(15, 23, 42, 0.95)')
bgGradient.addColorStop(0.4, 'rgba(15, 23, 42, 0.9)')
bgGradient.addColorStop(1, 'rgba(15, 23, 42, 0.7)')
ctx.fillStyle = bgGradient
ctx.beginPath()
ctx.arc(centerX, centerY, innerRadius + maxBarHeight + 15, 0, Math.PI * 2)
ctx.fill()
// 绘制频谱尖刺
const angleStep = (Math.PI * 2) / bars
const usableData = Math.floor(bufferLength * 0.7)
for (let i = 0; i < bars; i++) {
// 使用多个频率数据点的平均值来平滑
const dataIndex = Math.floor((i / bars) * usableData)
const nextIndex = Math.min(dataIndex + 1, usableData - 1)
const value = (dataArray[dataIndex] + dataArray[nextIndex]) / 2
// 添加一些随机变化使效果更自然
const randomFactor = 0.9 + Math.random() * 0.2
const barHeight = Math.max(3, (value / 255) * maxBarHeight * randomFactor)
const angle = i * angleStep - Math.PI / 2 // 从顶部开始
// 计算尖刺的起点和终点
const x1 = centerX + Math.cos(angle) * innerRadius
const y1 = centerY + Math.sin(angle) * innerRadius
const x2 = centerX + Math.cos(angle) * (innerRadius + barHeight)
const y2 = centerY + Math.sin(angle) * (innerRadius + barHeight)
// 创建渐变色 - 使用项目的蓝色/青色主题
const gradient = ctx.createLinearGradient(x1, y1, x2, y2)
const intensity = value / 255
// 根据强度调整颜色
if (intensity > 0.7) {
// 高强度 - 亮青色到白色
gradient.addColorStop(0, 'rgba(34, 211, 238, 0.9)')
gradient.addColorStop(0.5, 'rgba(56, 189, 248, 1)')
gradient.addColorStop(1, 'rgba(255, 255, 255, 1)')
} else if (intensity > 0.4) {
// 中等强度 - 蓝色到青色
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.8)')
gradient.addColorStop(0.5, 'rgba(34, 211, 238, 0.9)')
gradient.addColorStop(1, 'rgba(56, 189, 248, 1)')
} else {
// 低强度 - 深蓝到蓝色
gradient.addColorStop(0, 'rgba(30, 64, 175, 0.6)')
gradient.addColorStop(0.5, 'rgba(59, 130, 246, 0.7)')
gradient.addColorStop(1, 'rgba(96, 165, 250, 0.8)')
}
// 绘制尖刺线条
ctx.beginPath()
ctx.strokeStyle = gradient
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
// 在高强度时添加发光效果
if (intensity > 0.5) {
ctx.beginPath()
ctx.strokeStyle = `rgba(56, 189, 248, ${intensity * 0.3})`
ctx.lineWidth = 4
ctx.lineCap = 'round'
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
}
// 绘制内圈发光效果
const innerGlow = ctx.createRadialGradient(centerX, centerY, innerRadius - 10, centerX, centerY, innerRadius + 5)
innerGlow.addColorStop(0, 'rgba(56, 189, 248, 0.1)')
innerGlow.addColorStop(0.5, 'rgba(34, 211, 238, 0.15)')
innerGlow.addColorStop(1, 'rgba(34, 211, 238, 0)')
ctx.fillStyle = innerGlow
ctx.beginPath()
ctx.arc(centerX, centerY, innerRadius + 5, 0, Math.PI * 2)
ctx.fill()
// 绘制中心圆 - 深色背景
const centerGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius - 5)
centerGradient.addColorStop(0, 'rgba(15, 23, 42, 1)')
centerGradient.addColorStop(0.7, 'rgba(15, 23, 42, 0.95)')
centerGradient.addColorStop(1, 'rgba(30, 41, 59, 0.9)')
ctx.fillStyle = centerGradient
ctx.beginPath()
ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2)
ctx.fill()
// 绘制中心圆边框
ctx.strokeStyle = 'rgba(56, 189, 248, 0.3)'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2)
ctx.stroke()
// 绘制中心音符图标
ctx.fillStyle = 'rgba(56, 189, 248, 0.9)'
ctx.font = `${innerRadius * 0.5}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('♪', centerX, centerY)
animationRef.current = requestAnimationFrame(draw)
}, [config])
// 处理播放状态变化
useEffect(() => {
if (isPlaying && audioElement) {
setShowCover(false)
// 使用全局 WeakMap 来跟踪初始化状态,确保所有实例共享状态
let audioState = globalAudioInitState.get(audioElement)
if (!audioState) {
audioState = {
source: null,
analyser: null,
initialized: false,
failed: false,
initializing: false,
}
globalAudioInitState.set(audioElement, audioState)
}
const audioElementChanged = connectedAudioRef.current !== audioElement
// 检查是否需要初始化
const needsInit =
!audioState.failed && // 没有失败过
!audioState.initialized && // 没有成功初始化过
!audioState.initializing && // 没有正在初始化
(audioElementChanged || !sourceRef.current) // 需要初始化
if (needsInit) {
// 标记正在初始化(全局状态)
audioState.initializing = true
try {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()
}
const audioContext = audioContextRef.current
// 如果音频元素改变,需要清理旧的连接并创建新的
if (audioElementChanged || !analyserRef.current) {
// 清理旧的 source 连接
if (sourceRef.current) {
try {
sourceRef.current.disconnect()
} catch (e) {
// 忽略断开连接的错误
}
sourceRef.current = null
}
// 清理旧的 analyser
if (analyserRef.current) {
try {
analyserRef.current.disconnect()
} catch (e) {
// 忽略断开连接的错误
}
analyserRef.current = null
}
// 创建新的 analyser
const analyser = audioContext.createAnalyser()
analyser.fftSize = 512
analyser.smoothingTimeConstant = 0.75
// 尝试创建 source 连接
try {
const source = audioContext.createMediaElementSource(audioElement)
source.connect(analyser)
analyser.connect(audioContext.destination)
// 更新全局状态
audioState.source = source
audioState.analyser = analyser
audioState.initialized = true
audioState.failed = false
audioState.initializing = false
// 更新当前实例的 refs
sourceRef.current = source
analyserRef.current = analyser
connectedAudioRef.current = audioElement
console.log('[AudioVisualizer] Audio context initialized for new audio element')
setIsVisualizerActive(true)
} catch (sourceError: any) {
// 如果创建 source 失败(通常是已经连接过),只记录警告但不抛出错误
if (sourceError.name === 'InvalidStateError' || sourceError.message?.includes('already connected')) {
console.warn('[AudioVisualizer] Audio element already connected to another source, skipping visualization')
// 更新全局状态
audioState.source = null
audioState.analyser = null
audioState.initialized = false
audioState.failed = true
audioState.initializing = false
// 更新当前实例的 refs
analyserRef.current = null
connectedAudioRef.current = audioElement
// 不设置 isVisualizerActive,保持封面显示
} else {
// 其他错误仍然抛出
audioState.initializing = false
throw sourceError
}
}
}
} catch (error) {
console.error('[AudioVisualizer] Failed to initialize audio context:', error)
audioState.initializing = false
}
} else if (audioState.initialized && audioState.source) {
// 已经初始化过,复用全局的 source 和 analyser
sourceRef.current = audioState.source
analyserRef.current = audioState.analyser
connectedAudioRef.current = audioElement
setIsVisualizerActive(true)
}
// 恢复 audioContext(如果需要)
if (audioContextRef.current?.state === 'suspended') {
audioContextRef.current.resume()
}
// 启动绘制循环
requestAnimationFrame(() => {
if (analyserRef.current) {
draw()
}
})
} else if (!isPlaying) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
if (showCoverOnPause) {
setShowCover(true)
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [isPlaying, audioElement, draw, showCoverOnPause])
// 清理
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [])
const containerClasses = {
sm: 'w-40 h-40',
md: 'w-[280px] h-[280px]',
lg: 'w-[400px] h-[400px]',
}
return (
<div className={cn('relative overflow-hidden rounded-2xl', containerClasses[size], className)}>
{/* 封面图层 */}
<div
className={cn(
'absolute inset-0 transition-opacity duration-300 z-10',
showCover && !isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
{coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverImage}
alt="Audio cover"
className="w-full h-full object-cover rounded-2xl"
/>
) : (
<DefaultAudioCover size={size} />
)}
</div>
{/* 可视化层 */}
<div
className={cn(
'absolute inset-0 transition-opacity duration-300',
isPlaying || isVisualizerActive ? 'opacity-100' : 'opacity-0'
)}
>
<canvas
ref={canvasRef}
width={config.width}
height={config.height}
className="w-full h-full"
/>
</div>
{/* 未播放时的默认背景(当没有封面图时) */}
{!coverImage && !isPlaying && !showCover && (
<DefaultAudioCover size={size} />
)}
</div>
)
}

View File

@ -2,6 +2,8 @@
import { useState, useEffect, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
interface Danmaku {
id: string
@ -18,6 +20,7 @@ export default function DanmakuWidget() {
const [sending, setSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { toast, showToast } = useToast()
// 滚动到底部
const scrollToBottom = () => {
@ -83,11 +86,11 @@ export default function DanmakuWidget() {
setTimeout(scrollToBottom, 100)
} else {
const error = await response.json()
alert(error.error || '发送失败')
showToast(error.error || '发送失败', { variant: 'error' })
}
} catch (error) {
console.error('Send danmaku error:', error)
alert('发送失败,请稍后重试')
showToast('发送失败,请稍后重试', { variant: 'error' })
} finally {
setSending(false)
}
@ -245,6 +248,11 @@ export default function DanmakuWidget() {
animation: fade-in 0.3s ease-out;
}
`}</style>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -0,0 +1,526 @@
'use client'
import { useEffect, useState } from 'react'
interface DepartmentPermission {
department: string
materialType: string
canView: boolean
canDownload: boolean
canShare: boolean
linkedToDepartment: string | null
}
interface DepartmentPermissionsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
departments: Array<{ id: string; name: string }>
}
export default function DepartmentPermissionsTab({
showToast,
departments,
}: DepartmentPermissionsTabProps) {
const [loading, setLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [expandedDepartments, setExpandedDepartments] = useState<Set<string>>(new Set())
const [permissions, setPermissions] = useState<Record<string, Record<string, DepartmentPermission>>>({})
const [editedPermissions, setEditedPermissions] = useState<Record<string, Record<string, DepartmentPermission>>>({})
const [dirtyDepartments, setDirtyDepartments] = useState<Set<string>>(new Set())
const [linkedDepartments, setLinkedDepartments] = useState<Record<string, string>>({})
const [materialTypes, setMaterialTypes] = useState<string[]>([])
const [savingDept, setSavingDept] = useState<string | null>(null)
useEffect(() => {
fetchPermissions()
}, [])
const fetchPermissions = async () => {
try {
setLoading(true)
const url = searchKeyword
? `/api/admin/department-permissions?search=${encodeURIComponent(searchKeyword)}`
: '/api/admin/department-permissions'
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
setPermissions(data.permissions || {})
setEditedPermissions(data.permissions || {})
setDirtyDepartments(new Set())
setLinkedDepartments(data.linkedDepartments || {})
setMaterialTypes(data.materialTypes || [])
} else {
showToast('获取权限配置失败', 'error')
}
} catch (error) {
console.error('Error fetching permissions:', error)
showToast('获取权限配置失败', 'error')
} finally {
setLoading(false)
}
}
const toggleDepartment = (dept: string) => {
setExpandedDepartments((prev) => {
const newSet = new Set(prev)
if (newSet.has(dept)) {
newSet.delete(dept)
} else {
newSet.add(dept)
}
return newSet
})
}
const markDirty = (department: string) => {
setDirtyDepartments((prev) => {
const next = new Set(prev)
next.add(department)
return next
})
}
const buildPermission = (
department: string,
materialType: string,
base?: DepartmentPermission
): DepartmentPermission => {
return (
base || {
department,
materialType,
canView: false,
canDownload: false,
canShare: true,
linkedToDepartment: linkedDepartments[department] || null,
}
)
}
const updateLocalPermission = (
department: string,
materialType: string,
field: 'canView' | 'canDownload' | 'canShare',
value: boolean
) => {
setEditedPermissions((prev) => {
const deptPerms = { ...(prev[department] || permissions[department] || {}) }
const current = buildPermission(department, materialType, deptPerms[materialType])
deptPerms[materialType] = { ...current, [field]: value }
return { ...prev, [department]: deptPerms }
})
markDirty(department)
}
const handleToggleAll = (
department: string,
field: 'canView' | 'canDownload' | 'canShare',
value: boolean
) => {
if (linkedDepartments[department]) return
setEditedPermissions((prev) => {
const deptPerms = { ...(prev[department] || permissions[department] || {}) }
materialTypes.forEach((mt) => {
const current = buildPermission(department, mt, deptPerms[mt])
deptPerms[mt] = { ...current, [field]: value }
})
return { ...prev, [department]: deptPerms }
})
markDirty(department)
}
const handleSaveDepartment = async (department: string) => {
if (linkedDepartments[department]) {
showToast('已关联的部门不可单独保存,请先取消关联', 'error')
return
}
if (!dirtyDepartments.has(department)) {
showToast('没有需要保存的更改', 'error')
return
}
const deptPerms = editedPermissions[department] || {}
const payload = materialTypes.map((mt) => {
const perm = buildPermission(department, mt, deptPerms[mt])
return {
materialType: mt,
canView: perm.canView,
canDownload: perm.canDownload,
canShare: perm.canShare,
}
})
try {
setSavingDept(department)
const response = await fetch('/api/admin/department-permissions/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
department,
permissions: payload,
}),
})
if (response.ok) {
showToast('已保存')
await fetchPermissions()
} else {
const error = await response.json()
showToast(error.error || '保存失败', 'error')
}
} catch (error) {
console.error('Error saving permissions:', error)
showToast('保存失败', 'error')
} finally {
setSavingDept(null)
}
}
const handleCopyPermissions = async (sourceDept: string) => {
const targetDepts = departments
.map((d) => d.name)
.filter((name) => name !== sourceDept)
if (targetDepts.length === 0) {
showToast('没有可复制的目标部门', 'error')
return
}
const confirmed = window.confirm(
`确定要将"${sourceDept}"的权限配置复制到所有其他部门吗?\n这将覆盖目标部门的所有权限配置。`
)
if (!confirmed) return
try {
const response = await fetch('/api/admin/department-permissions/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceDepartment: sourceDept,
targetDepartments: targetDepts,
}),
})
if (response.ok) {
await fetchPermissions()
showToast('权限复制成功')
} else {
const error = await response.json()
showToast(error.error || '复制失败', 'error')
}
} catch (error) {
console.error('Error copying permissions:', error)
showToast('复制失败', 'error')
}
}
const handleLinkPermissions = async (targetDept: string) => {
const sourceDepts = departments
.map((d) => d.name)
.filter((name) => name !== targetDept)
if (sourceDepts.length === 0) {
showToast('没有可关联的源部门', 'error')
return
}
const sourceDept = window.prompt(
`请输入要关联的源部门名称:\n可用部门:${sourceDepts.join(', ')}`,
''
)
if (!sourceDept || !sourceDepts.includes(sourceDept)) {
return
}
try {
const response = await fetch('/api/admin/department-permissions/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetDepartment: targetDept,
sourceDepartment: sourceDept,
}),
})
if (response.ok) {
await fetchPermissions()
showToast('权限关联成功')
} else {
const error = await response.json()
showToast(error.error || '关联失败', 'error')
}
} catch (error) {
console.error('Error linking permissions:', error)
showToast('关联失败', 'error')
}
}
const handleUnlinkPermissions = async (department: string) => {
const confirmed = window.confirm(`确定要取消"${department}"的权限关联吗?`)
if (!confirmed) return
try {
const response = await fetch(
`/api/admin/department-permissions/link?department=${encodeURIComponent(department)}`,
{ method: 'DELETE' }
)
if (response.ok) {
await fetchPermissions()
showToast('已取消关联')
} else {
const error = await response.json()
showToast(error.error || '取消失败', 'error')
}
} catch (error) {
console.error('Error unlinking permissions:', error)
showToast('取消失败', 'error')
}
}
const filteredDepartments = searchKeyword
? departments.filter((d) => d.name.includes(searchKeyword))
: departments
if (loading) {
return (
<div className="card-apple">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">加载中...</div>
</div>
)
}
return (
<div className="card-apple">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">部门权限配置</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
配置各部门对不同素材类型的查看、下载、分享权限
</p>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
fetchPermissions()
}
}}
placeholder="搜索部门名称..."
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all"
/>
<button
onClick={fetchPermissions}
className="px-4 py-2 bg-gray-900 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-600 transition-colors"
>
搜索
</button>
</div>
</div>
<div className="p-6 space-y-2">
{filteredDepartments.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{searchKeyword ? '未找到匹配的部门' : '暂无部门'}
</div>
) : (
filteredDepartments.map((dept) => {
const deptName = dept.name
const isExpanded = expandedDepartments.has(deptName)
const linkedTo = linkedDepartments[deptName]
const deptPermissions = editedPermissions[deptName] || {}
const isDirty = dirtyDepartments.has(deptName)
return (
<div
key={dept.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
>
<div
className="px-4 py-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-between"
onClick={() => toggleDepartment(deptName)}
>
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-400">
{isExpanded ? '▼' : '▶'}
</span>
<span className="font-medium text-gray-900 dark:text-white">{deptName}</span>
{linkedTo && (
<span className="text-xs text-blue-600 dark:text-blue-400">
(已关联到: {linkedTo})
</span>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleCopyPermissions(deptName)}
className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
title="复制权限"
>
复制
</button>
{linkedTo ? (
<button
onClick={() => handleUnlinkPermissions(deptName)}
className="px-3 py-1 text-sm bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
title="取消关联"
>
取消关联
</button>
) : (
<button
onClick={() => handleLinkPermissions(deptName)}
className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
title="关联权限"
>
关联
</button>
)}
</div>
</div>
{isExpanded && (
<div className="p-4 bg-white dark:bg-gray-900">
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-sm text-gray-600 dark:text-gray-400">批量操作:</span>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canView', true)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
全选可见
</button>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canView', false)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
取消可见
</button>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canDownload', true)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
全选下载
</button>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canDownload', false)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
取消下载
</button>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canShare', true)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
全选分享
</button>
<button
disabled={!!linkedTo}
onClick={() => handleToggleAll(deptName, 'canShare', false)}
className="px-3 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50"
>
取消分享
</button>
<div className="flex-1" />
<button
disabled={!!linkedTo || !isDirty || savingDept === deptName}
onClick={() => handleSaveDepartment(deptName)}
className="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{savingDept === deptName ? '保存中...' : isDirty ? '保存更改' : '已保存'}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 text-gray-700 dark:text-gray-300">
素材类型
</th>
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
可见
</th>
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
下载
</th>
<th className="text-center py-2 px-3 text-gray-700 dark:text-gray-300">
分享
</th>
</tr>
</thead>
<tbody>
{materialTypes.map((materialType) => {
const perm = deptPermissions[materialType]
const isLinked = !!linkedTo
const canView = perm?.canView ?? false
const canDownload = perm?.canDownload ?? false
const canShare = perm?.canShare ?? true
return (
<tr
key={materialType}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="py-2 px-3 text-gray-900 dark:text-gray-100">
{materialType}
</td>
<td className="py-2 px-3 text-center">
<input
type="checkbox"
checked={canView}
disabled={isLinked}
onChange={(e) =>
updateLocalPermission(deptName, materialType, 'canView', e.target.checked)
}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</td>
<td className="py-2 px-3 text-center">
<input
type="checkbox"
checked={canDownload}
disabled={isLinked}
onChange={(e) =>
updateLocalPermission(deptName, materialType, 'canDownload', e.target.checked)
}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</td>
<td className="py-2 px-3 text-center">
<input
type="checkbox"
checked={canShare}
disabled={isLinked}
onChange={(e) =>
updateLocalPermission(deptName, materialType, 'canShare', e.target.checked)
}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@
import { useState, useRef, useEffect } from 'react'
import Image from 'next/image'
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, shouldPassRenderer } from '@/lib/material-validation'
interface EditMaterialModalProps {
material: {
@ -10,6 +11,8 @@ interface EditMaterialModalProps {
productModel: string
categoryName: string
sceneName: string
platformName?: string | null
productCategoryName?: string | null
materialType: string
renderer: string
authorName: string
@ -24,11 +27,15 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
const [productModel, setProductModel] = useState(material.productModel)
const [categoryName, setCategoryName] = useState(material.categoryName)
const [sceneName, setSceneName] = useState(material.sceneName)
const [materialType, setMaterialType] = useState(material.materialType || '3DMAX')
const [platformName, setPlatformName] = useState(material.platformName || '')
const [productCategoryName, setProductCategoryName] = useState(material.productCategoryName || '')
const [materialType, setMaterialType] = useState(material.materialType || '3D场景')
const [renderer, setRenderer] = useState(material.renderer || 'CR')
const [authorName, setAuthorName] = useState(material.authorName)
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
@ -42,6 +49,8 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
useEffect(() => {
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchProductCategories()
}, [])
const fetchCategories = async () => {
@ -68,6 +77,30 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
}
}
const fetchPlatforms = async () => {
try {
const response = await fetch('/api/platforms')
if (response.ok) {
const data = await response.json()
setPlatforms(data)
}
} catch (error) {
console.error('Error fetching platforms:', error)
}
}
const fetchProductCategories = async () => {
try {
const response = await fetch('/api/product-categories')
if (response.ok) {
const data = await response.json()
setProductCategories(data)
}
} catch (error) {
console.error('Error fetching product categories:', error)
}
}
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) {
setError('请输入一级分类名称')
@ -149,8 +182,25 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
e.preventDefault()
setError('')
if (!productModel || !categoryName || !sceneName || !renderer || !authorName) {
setError('请填写所有字段')
// 基础必填字段验证
if (!productModel || !materialType || !authorName) {
setError('请填写所有必填字段')
return
}
// 使用统一的验证逻辑
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
if (requiresProductCategoryName(materialType) && !productCategoryName) {
setError('标准模型类型必须选择品类')
return
}
if (requiresRenderer(materialType) && !renderer) {
setError('3D场景类型必须指定渲染器')
return
}
@ -162,8 +212,14 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
formData.append('productModel', productModel)
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
if (platformName) formData.append('platformName', platformName)
if (productCategoryName) formData.append('productCategoryName', productCategoryName)
formData.append('materialType', materialType)
formData.append('renderer', renderer)
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, renderer)
if (rendererInfo.shouldPass) {
formData.append('renderer', rendererInfo.value)
}
formData.append('authorName', authorName)
if (previewImage) {
@ -403,17 +459,29 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
className="input-apple"
required
>
<option value="3DMAX">3DMAX</option>
<option value="PSD">PSD</option>
<option value="AI">AI</option>
<option value="3D场景">3D场景</option>
<option value="详情页">详情页</option>
<option value="主图模版">主图模版</option>
<option value="SKU模版">SKU模版</option>
<option value="主图视频">主图视频</option>
<option value="安装视频">安装视频</option>
<option value="种草视频">种草视频</option>
<option value="模特">模特</option>
<option value="实拍图片">实拍图片</option>
<option value="买家秀图片">买家秀图片</option>
<option value="小红书种草">小红书种草</option>
<option value="音乐">音乐</option>
<option value="C4D模型">C4D模型</option>
<option value="标准饰品">标准饰品</option>
<option value="品牌VI灵感">品牌VI灵感</option>
<option value="矢量文件">矢量文件</option>
<option value="CAD">CAD</option>
<option value="C4D">C4D</option>
<option value="其他">其他</option>
<option value="标准模型">标准模型</option>
</select>
</div>
{/* Renderer (仅3DMAX显示) */}
{materialType === '3DMAX' && (
{/* Renderer (仅3D场景显示) */}
{requiresRenderer(materialType) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
渲染器 *

View File

@ -6,6 +6,8 @@ import { signOut } from 'next-auth/react'
import { Session } from 'next-auth'
import { useState, useRef, useEffect } from 'react'
import { initializeNetwork } from '@/lib/network'
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal'
interface HeaderProps {
session: Session | null
@ -18,8 +20,12 @@ export default function Header({ session }: HeaderProps) {
const [networkChecked, setNetworkChecked] = useState(false)
const [showNetworkBanner, setShowNetworkBanner] = useState(false)
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const [showNetworkDiagnose, setShowNetworkDiagnose] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// 使用 useUpdateLog hook 来管理更新日志弹窗
const { showModal: showUpdateLogModal, currentLog, showAllHistory, handleClose: handleUpdateLogClose } = useUpdateLog()
// 系统设置
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
@ -189,9 +195,20 @@ export default function Header({ session }: HeaderProps) {
className="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all"
>
{/* 圆形头像 */}
<div className="w-8 h-8 rounded-full bg-white dark:bg-gray-300 border-2 border-gray-700 dark:border-gray-600 flex items-center justify-center text-gray-900 dark:text-gray-800 font-semibold text-base shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all">
{getInitials(session.user?.name || '')}
</div>
{(session.user as any)?.avatar ? (
<Image
src={(session.user as any).avatar}
alt={session.user?.name || '用户'}
width={32}
height={32}
className="w-8 h-8 rounded-full border-2 border-gray-700 dark:border-gray-600 shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all object-cover"
unoptimized
/>
) : (
<div className="w-8 h-8 rounded-full bg-white dark:bg-gray-300 border-2 border-gray-700 dark:border-gray-600 flex items-center justify-center text-gray-900 dark:text-gray-800 font-semibold text-base shadow-lg hover:border-gray-500 dark:hover:border-gray-400 transition-all">
{getInitials(session.user?.name || '')}
</div>
)}
</button>
{/* 下拉菜单 */}
@ -206,6 +223,46 @@ export default function Header({ session }: HeaderProps) {
</p>
</div>
{/* 个人中心 */}
<Link
href="/profile"
onClick={() => setShowDropdown(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>个人中心</span>
</Link>
{/* 更新记录 */}
<button
onClick={() => {
setShowDropdown(false)
showUpdateLog('all') // 显示所有历史记录
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>更新记录</span>
</button>
{/* 提交反馈 */}
<a
href="https://alidocs.dingtalk.com/i/nodes/NZQYprEoWoeEabj3srv6RwDkJ1waOeDk?utm_scene=person_space"
target="_blank"
rel="noopener noreferrer"
onClick={() => setShowDropdown(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<span>提交反馈</span>
</a>
{/* 系统设置(管理员) */}
{isAdmin && (
<Link
@ -235,11 +292,26 @@ export default function Header({ session }: HeaderProps) {
<span>系统设置</span>
</Link>
)}
{/* 网络诊断 */}
<button
onClick={() => {
setShowDropdown(false)
setShowNetworkDiagnose(true)
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>网络诊断</span>
</button>
{/* 退出登录 */}
<button
onClick={() => {
setShowDropdown(false)
// 使用相对路径,避免被 NEXTAUTH_URL 等配置强制回落到 localhost
signOut({ callbackUrl: '/login', redirect: true })
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-all flex items-center space-x-2"
@ -267,6 +339,20 @@ export default function Header({ session }: HeaderProps) {
</div>
</div>
</header>
{/* 更新记录弹窗 */}
<UpdateLogModal
isOpen={showUpdateLogModal}
onClose={handleUpdateLogClose}
updateLog={currentLog || undefined}
showAllHistory={showAllHistory}
/>
{/* 网络诊断弹窗 */}
<NetworkDiagnoseModal
isOpen={showNetworkDiagnose}
onClose={() => setShowNetworkDiagnose(false)}
/>
</>
)
}

View File

@ -0,0 +1,799 @@
'use client'
import { useState } from 'react'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
interface NetworkDiagnoseModalProps {
isOpen: boolean
onClose: () => void
}
interface TestResult {
type: 'download' | 'upload' | 'storage' | 'preview-image' | 'preview-video' | 'preview-audio' | 'dns' | 'connection'
status: 'testing' | 'success' | 'error' | 'warning'
speed?: number
message?: string
details?: {
dnsTime?: number
connectTime?: number
ttfb?: number // Time To First Byte
downloadTime?: number
totalTime?: number
fileSize?: number
url?: string
}
}
export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnoseModalProps) {
const [testing, setTesting] = useState(false)
const [results, setResults] = useState<TestResult[]>([])
const [copied, setCopied] = useState(false)
const { toast, showToast } = useToast()
// 格式化速度
const formatSpeed = (bytesPerSecond: number) => {
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(2)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`
return `${(bytesPerSecond / 1024 / 1024).toFixed(2)} MB/s`
}
// 下载测速
const testDownloadSpeed = async (): Promise<TestResult> => {
try {
const testFileSize = 1024 * 1024 // 1MB
const startTime = Date.now()
const response = await fetch('/api/network/test-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ size: testFileSize })
})
if (!response.ok) throw new Error('下载测试失败')
const blob = await response.blob()
const endTime = Date.now()
const duration = (endTime - startTime) / 1000 // 秒
const speed = blob.size / duration // 字节/秒
return {
type: 'download',
status: 'success',
speed,
message: `下载速度: ${formatSpeed(speed)}`
}
} catch (error) {
return {
type: 'download',
status: 'error',
message: '下载测试失败'
}
}
}
// 上传测速
const testUploadSpeed = async (): Promise<TestResult> => {
try {
const testData = new Blob([new ArrayBuffer(1024 * 1024)]) // 1MB
const formData = new FormData()
formData.append('file', testData, 'test.bin')
const startTime = Date.now()
const response = await fetch('/api/network/test-upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error('上传测试失败')
const endTime = Date.now()
const duration = (endTime - startTime) / 1000 // 秒
const speed = testData.size / duration // 字节/秒
return {
type: 'upload',
status: 'success',
speed,
message: `上传速度: ${formatSpeed(speed)}`
}
} catch (error) {
return {
type: 'upload',
status: 'error',
message: '上传测试失败'
}
}
}
// 存储连通性测试
const testStorageConnectivity = async (): Promise<TestResult> => {
try {
const response = await fetch('/api/network/test-storage')
if (!response.ok) throw new Error('存储连通性测试失败')
const data = await response.json()
return {
type: 'storage',
status: 'success',
message: data.message || '存储服务连接正常'
}
} catch (error) {
return {
type: 'storage',
status: 'error',
message: '存储服务连接失败'
}
}
}
// 预览图片加载测试
const testPreviewImageLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的预览图片URL
const response = await fetch('/api/materials?pageSize=1&materialType=3D场景')
if (!response.ok) throw new Error('获取素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-image',
status: 'warning',
message: '没有可测试的预览图片'
}
}
const imageUrl = materials[0].previewUrl
if (!imageUrl) {
return {
type: 'preview-image',
status: 'warning',
message: '素材没有预览图'
}
}
// 使用Performance API测量加载时间
const startTime = performance.now()
const img = new Image()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = imageUrl
})
const endTime = performance.now()
const loadTime = endTime - startTime
// 估算文件大小(通过fetch获取)
const sizeResponse = await fetch(imageUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
const speed = fileSize > 0 ? (fileSize / (loadTime / 1000)) : 0
return {
type: 'preview-image',
status: loadTime > 3000 ? 'warning' : 'success',
speed,
message: `加载时间: ${(loadTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(${formatSpeed(speed)})` : ''}`,
details: {
totalTime: loadTime,
fileSize,
url: imageUrl
}
}
} catch (error) {
return {
type: 'preview-image',
status: 'error',
message: `预览图片加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 预览视频加载测试
const testPreviewVideoLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的视频素材
const response = await fetch('/api/materials?pageSize=1&materialType=主图视频')
if (!response.ok) throw new Error('获取视频素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-video',
status: 'warning',
message: '没有可测试的视频素材'
}
}
const videoUrl = materials[0].videoPreviewClipUrl || materials[0].videoPreview540Url || materials[0].videoSourceUrl
if (!videoUrl) {
return {
type: 'preview-video',
status: 'warning',
message: '视频素材没有预览URL'
}
}
const startTime = performance.now()
// 测试视频元数据加载(首字节时间)
const video = document.createElement('video')
video.preload = 'metadata'
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve
video.onerror = reject
video.src = videoUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
const sizeResponse = await fetch(videoUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
return {
type: 'preview-video',
status: metadataTime > 5000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: videoUrl
}
}
} catch (error) {
return {
type: 'preview-video',
status: 'error',
message: `视频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 音频文件加载测试
const testPreviewAudioLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的音频素材
const response = await fetch('/api/materials?pageSize=1&materialType=音乐')
if (!response.ok) throw new Error('获取音频素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-audio',
status: 'warning',
message: '没有可测试的音频素材'
}
}
const audioUrl = materials[0].zipFileName
if (!audioUrl) {
return {
type: 'preview-audio',
status: 'warning',
message: '音频素材没有URL'
}
}
const startTime = performance.now()
// 测试音频元数据加载
const audio = new Audio()
audio.preload = 'metadata'
await new Promise((resolve, reject) => {
audio.onloadedmetadata = resolve
audio.onerror = reject
audio.src = audioUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
const sizeResponse = await fetch(audioUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
return {
type: 'preview-audio',
status: metadataTime > 3000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: audioUrl
}
}
} catch (error) {
return {
type: 'preview-audio',
status: 'error',
message: `音频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// S3存储性能分析(包含frp检测和带宽测试)
const testS3Performance = async (): Promise<TestResult> => {
try {
// 获取一个真实的预览图进行测试
const response = await fetch('/api/materials?pageSize=1')
if (!response.ok) throw new Error('获取测试素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'dns',
status: 'warning',
message: '没有可测试的素材'
}
}
const testUrl = materials[0].previewUrl
if (!testUrl) {
return {
type: 'dns',
status: 'warning',
message: '素材没有预览URL'
}
}
// 提取S3域名
const urlObj = new URL(testUrl)
const s3Domain = urlObj.hostname
// 测试1: 检测是否使用frp
let isFrp = false
let frpInfo = ''
try {
const frpTestUrl = `${urlObj.protocol}//${s3Domain}/`
const frpResponse = await fetch(frpTestUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000)
})
const text = await frpResponse.text()
if (text.includes('frp') || text.includes('内网穿透')) {
isFrp = true
frpInfo = '🔴 检测到frp内网穿透服务'
}
} catch (e) {
// 忽略错误
}
// 测试2: 下载速度和TTFB测试
const startTime = performance.now()
let ttfb = 0
const testResponse = await fetch(testUrl, {
signal: AbortSignal.timeout(30000)
})
ttfb = performance.now() - startTime
const blob = await testResponse.blob()
const totalTime = performance.now() - startTime
const fileSizeMB = blob.size / (1024 * 1024)
const downloadTime = (totalTime - ttfb) / 1000 // 秒
const downloadSpeed = downloadTime > 0 ? fileSizeMB / downloadTime : 0 // MB/s
// 分析结果
const issues: string[] = []
const suggestions: string[] = []
if (isFrp) {
issues.push('🔴 使用frp内网穿透,带宽受限')
suggestions.push('• 推荐方案1: 使用CDN加速(成本低,见效快)')
suggestions.push('• 推荐方案2: 迁移到公网S3存储(彻底解决)')
suggestions.push('• 临时方案: 内网用户直接访问内网S3')
}
if (ttfb > 1000) {
issues.push(`🔴 首字节时间过长: ${Math.round(ttfb)}ms(正常<200ms)`)
if (!isFrp) {
suggestions.push('• TTFB过高可能是服务器响应慢或网络延迟大')
}
}
if (downloadSpeed < 1 && downloadSpeed > 0) {
issues.push(`🔴 下载速度过慢: ${downloadSpeed.toFixed(2)}MB/s(正常>10MB/s)`)
if (!isFrp) {
suggestions.push('• 带宽不足,建议升级服务器带宽或使用CDN')
}
}
const isSuccess = !isFrp && ttfb < 1000 && downloadSpeed > 1
const isWarning = !isSuccess && (ttfb < 3000 || downloadSpeed > 0.1)
let message = ''
if (isSuccess) {
message = `性能良好\n下载速度: ${downloadSpeed.toFixed(2)}MB/s, TTFB: ${Math.round(ttfb)}ms`
} else if (isWarning) {
message = `性能一般\n${issues.slice(0, 2).join('\n')}`
} else {
message = `性能较差\n${issues.slice(0, 2).join('\n')}`
}
return {
type: 'dns',
status: isSuccess ? 'success' : isWarning ? 'warning' : 'error',
message,
details: {
ttfb,
totalTime,
fileSize: blob.size,
url: testUrl
}
}
} catch (error) {
return {
type: 'dns',
status: 'error',
message: `S3性能测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// DNS和连接测试
const testDNSAndConnection = async (): Promise<TestResult> => {
try {
const testUrl = `${window.location.origin}/api/network-status`
// 使用Resource Timing API获取详细的网络时序
const startMark = `dns-test-${Date.now()}`
performance.mark(startMark)
const startTime = performance.now()
const response = await fetch(testUrl, { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('连接测试失败')
// 尝试获取Resource Timing信息
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes('/api/network-status'))
let dnsTime = 0
let connectTime = 0
let ttfb = 0
if (entry) {
dnsTime = entry.domainLookupEnd - entry.domainLookupStart
connectTime = entry.connectEnd - entry.connectStart
ttfb = entry.responseStart - entry.requestStart
}
const totalTime = endTime - startTime
return {
type: 'connection',
status: totalTime > 1000 ? 'warning' : 'success',
message: `总耗时: ${totalTime.toFixed(0)}ms`,
details: {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
ttfb: ttfb > 0 ? ttfb : undefined,
totalTime
}
}
} catch (error) {
return {
type: 'connection',
status: 'error',
message: `连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 开始诊断
const startDiagnose = async () => {
setTesting(true)
setResults([])
// 1. DNS和连接测试
setResults([{ type: 'connection', status: 'testing' }])
const connectionResult = await testDNSAndConnection()
setResults([connectionResult])
// 2. 下载测速
setResults(prev => [...prev, { type: 'download', status: 'testing' }])
const downloadResult = await testDownloadSpeed()
setResults(prev => [...prev.slice(0, -1), downloadResult])
// 3. 上传测速
setResults(prev => [...prev, { type: 'upload', status: 'testing' }])
const uploadResult = await testUploadSpeed()
setResults(prev => [...prev.slice(0, -1), uploadResult])
// 4. 存储连通性
setResults(prev => [...prev, { type: 'storage', status: 'testing' }])
const storageResult = await testStorageConnectivity()
setResults(prev => [...prev.slice(0, -1), storageResult])
// 5. 预览图片加载测试
setResults(prev => [...prev, { type: 'preview-image', status: 'testing' }])
const imageResult = await testPreviewImageLoad()
setResults(prev => [...prev.slice(0, -1), imageResult])
// 6. 预览视频加载测试
setResults(prev => [...prev, { type: 'preview-video', status: 'testing' }])
const videoResult = await testPreviewVideoLoad()
setResults(prev => [...prev.slice(0, -1), videoResult])
// 7. 音频文件加载测试
setResults(prev => [...prev, { type: 'preview-audio', status: 'testing' }])
const audioResult = await testPreviewAudioLoad()
setResults(prev => [...prev.slice(0, -1), audioResult])
// 8. S3存储性能分析(包含frp检测)
setResults(prev => [...prev, { type: 'dns', status: 'testing' }])
const s3Result = await testS3Performance()
setResults(prev => [...prev.slice(0, -1), s3Result])
setTesting(false)
}
// 复制结果(包含完整的诊断信息,用于AI分析)
const copyResults = async () => {
if (results.length === 0) return
// 生成详细的诊断报告
const timestamp = new Date().toLocaleString('zh-CN')
const report = [
'=== 网络诊断报告 ===',
`时间: ${timestamp}`,
`浏览器: ${navigator.userAgent}`,
`当前URL: ${window.location.href}`,
'',
'=== 测试结果 ===',
...results.map(r => {
const typeMap: Record<string, string> = {
'download': '下载测速',
'upload': '上传测速',
'storage': '存储连通性',
'preview-image': '预览图片加载',
'preview-video': '预览视频加载',
'preview-audio': '音频文件加载',
'connection': 'DNS和连接测试',
'dns': 'S3存储性能分析'
}
const typeText = typeMap[r.type] || r.type
const statusText = r.status === 'success' ? '✓ 成功' : r.status === 'error' ? '✗ 失败' : r.status === 'warning' ? '⚠ 警告' : '⋯ 测试中'
let detailsText = ''
if (r.details) {
const details = []
if (r.details.dnsTime) details.push(`DNS解析: ${r.details.dnsTime.toFixed(0)}ms`)
if (r.details.connectTime) details.push(`TCP连接: ${r.details.connectTime.toFixed(0)}ms`)
if (r.details.ttfb) details.push(`首字节时间: ${r.details.ttfb.toFixed(0)}ms`)
if (r.details.totalTime) details.push(`总耗时: ${r.details.totalTime.toFixed(0)}ms`)
if (r.details.fileSize) details.push(`文件大小: ${(r.details.fileSize / 1024 / 1024).toFixed(2)}MB`)
if (r.details.url) details.push(`URL: ${r.details.url}`)
if (details.length > 0) {
detailsText = '\n 详细信息: ' + details.join(', ')
}
}
return `${typeText}: ${statusText}\n ${r.message || '测试中...'}${detailsText}`
}),
'',
'=== 网络环境信息 ===',
`在线状态: ${navigator.onLine ? '在线' : '离线'}`,
`连接类型: ${(navigator as any).connection?.effectiveType || '未知'}`,
`下行速度: ${(navigator as any).connection?.downlink || '未知'} Mbps`,
`RTT延迟: ${(navigator as any).connection?.rtt || '未知'} ms`,
'',
'=== 性能分析 ===',
...results.filter(r => r.status === 'warning' || r.status === 'error').map(r => {
const typeMap: Record<string, string> = {
'preview-image': '预览图片',
'preview-video': '预览视频',
'preview-audio': '音频文件',
'connection': '网络连接'
}
const typeText = typeMap[r.type] || r.type
return `⚠ ${typeText}加载较慢或失败,可能影响用户体验`
}),
'',
'提示: 请将此报告提供给技术支持以便快速定位问题'
].join('\n')
try {
await navigator.clipboard.writeText(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('复制失败,尝试降级方案:', error)
// 降级方案:使用传统方法复制
const textarea = document.createElement('textarea')
textarea.value = report
textarea.style.position = 'fixed'
textarea.style.left = '-999999px'
textarea.style.top = '-999999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
} catch (err) {
console.error('降级复制也失败:', err)
showToast('复制失败,请手动复制结果', { variant: 'error' })
}
document.body.removeChild(textarea)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'testing':
return (
<svg className="w-5 h-5 text-blue-500 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'success':
return (
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
case 'warning':
return (
<svg className="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
case 'error':
return (
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
default:
return null
}
}
const getTypeText = (type: string) => {
switch (type) {
case 'download':
return '下载测速'
case 'upload':
return '上传测速'
case 'storage':
return '存储连通性'
case 'preview-image':
return '预览图片加载'
case 'preview-video':
return '预览视频加载'
case 'preview-audio':
return '音频文件加载'
case 'connection':
return 'DNS和连接'
case 'dns':
return 'S3存储性能'
default:
return ''
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl max-w-md w-full shadow-2xl">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">网络诊断</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
测试网络连接速度和存储服务状态
</p>
</div>
{/* Content */}
<div className="p-6 space-y-4 max-h-[60vh] overflow-y-auto">
{results.length === 0 ? (
<div className="text-center py-8">
<svg className="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500 dark:text-gray-400 mb-2">
点击下方按钮开始诊断
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
将测试网络速度、预览文件加载等性能指标
</p>
</div>
) : (
<div className="space-y-3">
{results.map((result, index) => (
<div
key={index}
className={`flex items-start gap-3 p-4 rounded-xl transition-all ${
result.status === 'warning'
? 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
: result.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: 'bg-gray-50 dark:bg-gray-700'
}`}
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(result.status)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{getTypeText(result.type)}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1">
{result.message || '测试中...'}
</p>
{result.details && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
{result.details.dnsTime !== undefined && (
<div>DNS解析: {result.details.dnsTime.toFixed(0)}ms</div>
)}
{result.details.connectTime !== undefined && (
<div>TCP连接: {result.details.connectTime.toFixed(0)}ms</div>
)}
{result.details.ttfb !== undefined && (
<div>首字节时间: {result.details.ttfb.toFixed(0)}ms</div>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
<button
onClick={startDiagnose}
disabled={testing}
className="w-full btn-primary disabled:opacity-50"
>
{testing ? '诊断中...' : results.length > 0 ? '重新诊断' : '开始诊断'}
</button>
{results.length > 0 && !testing && (
<button
onClick={copyResults}
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{copied ? '已复制' : '复制结果'}
</button>
)}
</div>
</div>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -0,0 +1,670 @@
'use client'
import { useState } from 'react'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
interface NetworkDiagnoseModalProps {
isOpen: boolean
onClose: () => void
}
interface TestResult {
type: 'download' | 'upload' | 'storage' | 'preview-image' | 'preview-video' | 'preview-audio' | 'dns' | 'connection'
status: 'testing' | 'success' | 'error' | 'warning'
speed?: number
message?: string
details?: {
dnsTime?: number
connectTime?: number
ttfb?: number // Time To First Byte
downloadTime?: number
totalTime?: number
fileSize?: number
url?: string
}
}
export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnoseModalProps) {
const [testing, setTesting] = useState(false)
const [results, setResults] = useState<TestResult[]>([])
const [copied, setCopied] = useState(false)
const { toast, showToast } = useToast()
// 格式化速度
const formatSpeed = (bytesPerSecond: number) => {
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(2)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`
return `${(bytesPerSecond / 1024 / 1024).toFixed(2)} MB/s`
}
// 下载测速
const testDownloadSpeed = async (): Promise<TestResult> => {
try {
const testFileSize = 1024 * 1024 // 1MB
const startTime = Date.now()
const response = await fetch('/api/network/test-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ size: testFileSize })
})
if (!response.ok) throw new Error('下载测试失败')
const blob = await response.blob()
const endTime = Date.now()
const duration = (endTime - startTime) / 1000 // 秒
const speed = blob.size / duration // 字节/秒
return {
type: 'download',
status: 'success',
speed,
message: `下载速度: ${formatSpeed(speed)}`
}
} catch (error) {
return {
type: 'download',
status: 'error',
message: '下载测试失败'
}
}
}
// 上传测速
const testUploadSpeed = async (): Promise<TestResult> => {
try {
const testData = new Blob([new ArrayBuffer(1024 * 1024)]) // 1MB
const formData = new FormData()
formData.append('file', testData, 'test.bin')
const startTime = Date.now()
const response = await fetch('/api/network/test-upload', {
method: 'POST',
body: formData
})
if (!response.ok) throw new Error('上传测试失败')
const endTime = Date.now()
const duration = (endTime - startTime) / 1000 // 秒
const speed = testData.size / duration // 字节/秒
return {
type: 'upload',
status: 'success',
speed,
message: `上传速度: ${formatSpeed(speed)}`
}
} catch (error) {
return {
type: 'upload',
status: 'error',
message: '上传测试失败'
}
}
}
// 存储连通性测试
const testStorageConnectivity = async (): Promise<TestResult> => {
try {
const response = await fetch('/api/network/test-storage')
if (!response.ok) throw new Error('存储连通性测试失败')
const data = await response.json()
return {
type: 'storage',
status: 'success',
message: data.message || '存储服务连接正常'
}
} catch (error) {
return {
type: 'storage',
status: 'error',
message: '存储服务连接失败'
}
}
}
// 预览图片加载测试
const testPreviewImageLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的预览图片URL
const response = await fetch('/api/materials?pageSize=1&materialType=3D场景')
if (!response.ok) throw new Error('获取素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-image',
status: 'warning',
message: '没有可测试的预览图片'
}
}
const imageUrl = materials[0].previewUrl
if (!imageUrl) {
return {
type: 'preview-image',
status: 'warning',
message: '素材没有预览图'
}
}
// 使用Performance API测量加载时间
const startTime = performance.now()
const img = new Image()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = imageUrl
})
const endTime = performance.now()
const loadTime = endTime - startTime
// 估算文件大小(通过fetch获取)
const sizeResponse = await fetch(imageUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
const speed = fileSize > 0 ? (fileSize / (loadTime / 1000)) : 0
return {
type: 'preview-image',
status: loadTime > 3000 ? 'warning' : 'success',
speed,
message: `加载时间: ${(loadTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(${formatSpeed(speed)})` : ''}`,
details: {
totalTime: loadTime,
fileSize,
url: imageUrl
}
}
} catch (error) {
return {
type: 'preview-image',
status: 'error',
message: `预览图片加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 预览视频加载测试
const testPreviewVideoLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的视频素材
const response = await fetch('/api/materials?pageSize=1&materialType=主图视频')
if (!response.ok) throw new Error('获取视频素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-video',
status: 'warning',
message: '没有可测试的视频素材'
}
}
const videoUrl = materials[0].videoPreviewClipUrl || materials[0].videoPreview540Url || materials[0].videoSourceUrl
if (!videoUrl) {
return {
type: 'preview-video',
status: 'warning',
message: '视频素材没有预览URL'
}
}
const startTime = performance.now()
// 测试视频元数据加载(首字节时间)
const video = document.createElement('video')
video.preload = 'metadata'
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve
video.onerror = reject
video.src = videoUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
const sizeResponse = await fetch(videoUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
return {
type: 'preview-video',
status: metadataTime > 5000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: videoUrl
}
}
} catch (error) {
return {
type: 'preview-video',
status: 'error',
message: `视频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 音频文件加载测试
const testPreviewAudioLoad = async (): Promise<TestResult> => {
try {
// 获取一个真实的音频素材
const response = await fetch('/api/materials?pageSize=1&materialType=音乐')
if (!response.ok) throw new Error('获取音频素材失败')
const data = await response.json()
const materials = data.materials || data
if (!materials || materials.length === 0) {
return {
type: 'preview-audio',
status: 'warning',
message: '没有可测试的音频素材'
}
}
const audioUrl = materials[0].zipFileName
if (!audioUrl) {
return {
type: 'preview-audio',
status: 'warning',
message: '音频素材没有URL'
}
}
const startTime = performance.now()
// 测试音频元数据加载
const audio = new Audio()
audio.preload = 'metadata'
await new Promise((resolve, reject) => {
audio.onloadedmetadata = resolve
audio.onerror = reject
audio.src = audioUrl
})
const metadataTime = performance.now() - startTime
// 获取文件大小
const sizeResponse = await fetch(audioUrl, { method: 'HEAD' })
const fileSize = parseInt(sizeResponse.headers.get('content-length') || '0')
return {
type: 'preview-audio',
status: metadataTime > 3000 ? 'warning' : 'success',
message: `元数据加载: ${(metadataTime / 1000).toFixed(2)}秒 ${fileSize > 0 ? `(文件: ${(fileSize / 1024 / 1024).toFixed(2)}MB)` : ''}`,
details: {
ttfb: metadataTime,
totalTime: metadataTime,
fileSize,
url: audioUrl
}
}
} catch (error) {
return {
type: 'preview-audio',
status: 'error',
message: `音频加载失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// DNS和连接测试
const testDNSAndConnection = async (): Promise<TestResult> => {
try {
const testUrl = `${window.location.origin}/api/network-status`
// 使用Resource Timing API获取详细的网络时序
const startMark = `dns-test-${Date.now()}`
performance.mark(startMark)
const startTime = performance.now()
const response = await fetch(testUrl, { cache: 'no-store' })
const endTime = performance.now()
if (!response.ok) throw new Error('连接测试失败')
// 尝试获取Resource Timing信息
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const entry = entries.find(e => e.name.includes('/api/network-status'))
let dnsTime = 0
let connectTime = 0
let ttfb = 0
if (entry) {
dnsTime = entry.domainLookupEnd - entry.domainLookupStart
connectTime = entry.connectEnd - entry.connectStart
ttfb = entry.responseStart - entry.requestStart
}
const totalTime = endTime - startTime
return {
type: 'connection',
status: totalTime > 1000 ? 'warning' : 'success',
message: `总耗时: ${totalTime.toFixed(0)}ms`,
details: {
dnsTime: dnsTime > 0 ? dnsTime : undefined,
connectTime: connectTime > 0 ? connectTime : undefined,
ttfb: ttfb > 0 ? ttfb : undefined,
totalTime
}
}
} catch (error) {
return {
type: 'connection',
status: 'error',
message: `连接测试失败: ${error instanceof Error ? error.message : '未知错误'}`
}
}
}
// 开始诊断
const startDiagnose = async () => {
setTesting(true)
setResults([])
// 1. DNS和连接测试
setResults([{ type: 'connection', status: 'testing' }])
const connectionResult = await testDNSAndConnection()
setResults([connectionResult])
// 2. 下载测速
setResults(prev => [...prev, { type: 'download', status: 'testing' }])
const downloadResult = await testDownloadSpeed()
setResults(prev => [...prev.slice(0, -1), downloadResult])
// 3. 上传测速
setResults(prev => [...prev, { type: 'upload', status: 'testing' }])
const uploadResult = await testUploadSpeed()
setResults(prev => [...prev.slice(0, -1), uploadResult])
// 4. 存储连通性
setResults(prev => [...prev, { type: 'storage', status: 'testing' }])
const storageResult = await testStorageConnectivity()
setResults(prev => [...prev.slice(0, -1), storageResult])
// 5. 预览图片加载测试
setResults(prev => [...prev, { type: 'preview-image', status: 'testing' }])
const imageResult = await testPreviewImageLoad()
setResults(prev => [...prev.slice(0, -1), imageResult])
// 6. 预览视频加载测试
setResults(prev => [...prev, { type: 'preview-video', status: 'testing' }])
const videoResult = await testPreviewVideoLoad()
setResults(prev => [...prev.slice(0, -1), videoResult])
// 7. 音频文件加载测试
setResults(prev => [...prev, { type: 'preview-audio', status: 'testing' }])
const audioResult = await testPreviewAudioLoad()
setResults(prev => [...prev.slice(0, -1), audioResult])
setTesting(false)
}
// 复制结果(包含完整的诊断信息,用于AI分析)
const copyResults = async () => {
if (results.length === 0) return
// 生成详细的诊断报告
const timestamp = new Date().toLocaleString('zh-CN')
const report = [
'=== 网络诊断报告 ===',
`时间: ${timestamp}`,
`浏览器: ${navigator.userAgent}`,
`当前URL: ${window.location.href}`,
'',
'=== 测试结果 ===',
...results.map(r => {
const typeMap: Record<string, string> = {
'download': '下载测速',
'upload': '上传测速',
'storage': '存储连通性',
'preview-image': '预览图片加载',
'preview-video': '预览视频加载',
'preview-audio': '音频文件加载',
'connection': 'DNS和连接测试'
}
const typeText = typeMap[r.type] || r.type
const statusText = r.status === 'success' ? '✓ 成功' : r.status === 'error' ? '✗ 失败' : r.status === 'warning' ? '⚠ 警告' : '⋯ 测试中'
let detailsText = ''
if (r.details) {
const details = []
if (r.details.dnsTime) details.push(`DNS解析: ${r.details.dnsTime.toFixed(0)}ms`)
if (r.details.connectTime) details.push(`TCP连接: ${r.details.connectTime.toFixed(0)}ms`)
if (r.details.ttfb) details.push(`首字节时间: ${r.details.ttfb.toFixed(0)}ms`)
if (r.details.totalTime) details.push(`总耗时: ${r.details.totalTime.toFixed(0)}ms`)
if (r.details.fileSize) details.push(`文件大小: ${(r.details.fileSize / 1024 / 1024).toFixed(2)}MB`)
if (r.details.url) details.push(`URL: ${r.details.url}`)
if (details.length > 0) {
detailsText = '\n 详细信息: ' + details.join(', ')
}
}
return `${typeText}: ${statusText}\n ${r.message || '测试中...'}${detailsText}`
}),
'',
'=== 网络环境信息 ===',
`在线状态: ${navigator.onLine ? '在线' : '离线'}`,
`连接类型: ${(navigator as any).connection?.effectiveType || '未知'}`,
`下行速度: ${(navigator as any).connection?.downlink || '未知'} Mbps`,
`RTT延迟: ${(navigator as any).connection?.rtt || '未知'} ms`,
'',
'=== 性能分析 ===',
...results.filter(r => r.status === 'warning' || r.status === 'error').map(r => {
const typeMap: Record<string, string> = {
'preview-image': '预览图片',
'preview-video': '预览视频',
'preview-audio': '音频文件',
'connection': '网络连接'
}
const typeText = typeMap[r.type] || r.type
return `⚠ ${typeText}加载较慢或失败,可能影响用户体验`
}),
'',
'提示: 请将此报告提供给技术支持以便快速定位问题'
].join('\n')
try {
await navigator.clipboard.writeText(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (error) {
console.error('复制失败,尝试降级方案:', error)
// 降级方案:使用传统方法复制
const textarea = document.createElement('textarea')
textarea.value = report
textarea.style.position = 'fixed'
textarea.style.left = '-999999px'
textarea.style.top = '-999999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
} catch (err) {
console.error('降级复制也失败:', err)
showToast('复制失败,请手动复制结果', { variant: 'error' })
}
document.body.removeChild(textarea)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'testing':
return (
<svg className="w-5 h-5 text-blue-500 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'success':
return (
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
case 'warning':
return (
<svg className="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
case 'error':
return (
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
default:
return null
}
}
const getTypeText = (type: string) => {
switch (type) {
case 'download':
return '下载测速'
case 'upload':
return '上传测速'
case 'storage':
return '存储连通性'
case 'preview-image':
return '预览图片加载'
case 'preview-video':
return '预览视频加载'
case 'preview-audio':
return '音频文件加载'
case 'connection':
return 'DNS和连接'
default:
return ''
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl max-w-md w-full shadow-2xl">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">网络诊断</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
测试网络连接速度和存储服务状态
</p>
</div>
{/* Content */}
<div className="p-6 space-y-4 max-h-[60vh] overflow-y-auto">
{results.length === 0 ? (
<div className="text-center py-8">
<svg className="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500 dark:text-gray-400 mb-2">
点击下方按钮开始诊断
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
将测试网络速度、预览文件加载等性能指标
</p>
</div>
) : (
<div className="space-y-3">
{results.map((result, index) => (
<div
key={index}
className={`flex items-start gap-3 p-4 rounded-xl transition-all ${
result.status === 'warning'
? 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
: result.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: 'bg-gray-50 dark:bg-gray-700'
}`}
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(result.status)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{getTypeText(result.type)}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1">
{result.message || '测试中...'}
</p>
{result.details && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
{result.details.dnsTime !== undefined && (
<div>DNS解析: {result.details.dnsTime.toFixed(0)}ms</div>
)}
{result.details.connectTime !== undefined && (
<div>TCP连接: {result.details.connectTime.toFixed(0)}ms</div>
)}
{result.details.ttfb !== undefined && (
<div>首字节时间: {result.details.ttfb.toFixed(0)}ms</div>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
<button
onClick={startDiagnose}
disabled={testing}
className="w-full btn-primary disabled:opacity-50"
>
{testing ? '诊断中...' : results.length > 0 ? '重新诊断' : '开始诊断'}
</button>
{results.length > 0 && !testing && (
<button
onClick={copyResults}
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
{copied ? '已复制' : '复制结果'}
</button>
)}
</div>
</div>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
interface RankingUser {
username: string
@ -13,6 +13,11 @@ interface RankingData {
likes: RankingUser[]
}
interface Position {
x: number
y: number
}
export default function RankingPanel() {
const [isVisible, setIsVisible] = useState(true)
const [currentTab, setCurrentTab] = useState<'contributors' | 'downloads' | 'likes'>('contributors')
@ -22,6 +27,57 @@ export default function RankingPanel() {
likes: []
})
const [loading, setLoading] = useState(true)
const [position, setPosition] = useState<Position>({ x: 32, y: window.innerHeight / 2 - 200 })
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [buttonPosition, setButtonPosition] = useState<Position>({ x: 32, y: window.innerHeight / 2 - 24 })
const [isButtonDragging, setIsButtonDragging] = useState(false)
const [buttonDragOffset, setButtonDragOffset] = useState<Position>({ x: 0, y: 0 })
const clickStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
// 从localStorage恢复状态
useEffect(() => {
try {
const saved = localStorage.getItem('ranking_panel_state')
if (saved) {
const state = JSON.parse(saved)
// 默认显示为迷你按钮(isVisible默认为false)
setIsVisible(state.isVisible ?? false)
if (state.position) {
setPosition(state.position)
}
if (state.buttonPosition) {
setButtonPosition(state.buttonPosition)
}
} else {
// 首次访问,默认为迷你按钮
setIsVisible(false)
}
} catch (error) {
console.error('恢复榜单面板状态失败:', error)
setIsVisible(false)
}
}, [])
// 保存状态到localStorage
const saveState = () => {
try {
localStorage.setItem('ranking_panel_state', JSON.stringify({
isVisible,
position,
buttonPosition
}))
} catch (error) {
console.error('保存榜单面板状态失败:', error)
}
}
// 状态变化时保存
useEffect(() => {
saveState()
}, [isVisible, position, buttonPosition])
// 自动轮播
useEffect(() => {
@ -31,7 +87,7 @@ export default function RankingPanel() {
const interval = setInterval(() => {
currentIndex = (currentIndex + 1) % tabs.length
setCurrentTab(tabs[currentIndex])
}, 5000) // 每5秒切换一次
}, 5000)
return () => clearInterval(interval)
}, [])
@ -53,17 +109,113 @@ export default function RankingPanel() {
}
fetchRankings()
// 每30秒更新一次数据
const interval = setInterval(fetchRankings, 30000)
return () => clearInterval(interval)
}, [])
const handleClose = () => {
setIsVisible(false)
// 不再保存到localStorage,刷新后会重新显示
// 拖拽处理
const handleMouseDown = (e: React.MouseEvent) => {
if (panelRef.current) {
setIsDragging(true)
const rect = panelRef.current.getBoundingClientRect()
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
}
}
if (!isVisible) return null
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x
const newY = e.clientY - dragOffset.y
// 边界约束
const maxX = window.innerWidth - 150
const maxY = window.innerHeight - 400
setPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
})
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, dragOffset])
// 缩略按钮拖动处理
const handleButtonMouseDown = (e: React.MouseEvent) => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
setButtonDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
setIsButtonDragging(true)
// 记录点击开始位置和时间
clickStartRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now()
}
}
}
useEffect(() => {
const handleButtonMouseMove = (e: MouseEvent) => {
if (isButtonDragging) {
const newX = e.clientX - buttonDragOffset.x
const newY = e.clientY - buttonDragOffset.y
// 边界约束
const maxX = window.innerWidth - 48
const maxY = window.innerHeight - 48
setButtonPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
})
}
}
const handleButtonMouseUp = () => {
setIsButtonDragging(false)
}
if (isButtonDragging) {
document.addEventListener('mousemove', handleButtonMouseMove)
document.addEventListener('mouseup', handleButtonMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleButtonMouseMove)
document.removeEventListener('mouseup', handleButtonMouseUp)
}
}, [isButtonDragging, buttonDragOffset])
const handleClose = () => {
setIsVisible(false)
}
const handleOpen = () => {
setIsVisible(true)
// 打开时使用按钮的当前位置
setPosition(buttonPosition)
}
const getMedalIcon = (index: number) => {
switch (index) {
@ -112,8 +264,53 @@ export default function RankingPanel() {
}
}
// 迷你按钮
if (!isVisible) {
return (
<button
ref={buttonRef}
onMouseDown={handleButtonMouseDown}
onClick={(e) => {
// 检查是否是拖动操作(移动超过5px或时间超过200ms)
if (clickStartRef.current) {
const deltaX = Math.abs(e.clientX - clickStartRef.current.x)
const deltaY = Math.abs(e.clientY - clickStartRef.current.y)
const deltaTime = Date.now() - clickStartRef.current.time
// 如果移动距离小于5px且时间小于200ms,认为是点击
if (deltaX < 5 && deltaY < 5 && deltaTime < 200 && !isButtonDragging) {
handleOpen()
}
clickStartRef.current = null
}
}}
className="fixed z-40 w-12 h-12 bg-gray-900 dark:bg-gray-950 rounded-full shadow-2xl border border-gray-700 dark:border-gray-800 flex items-center justify-center hover:scale-110 transition-transform"
style={{ left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
title="打开榜单面板(可拖动)"
>
<svg className="w-6 h-6 text-yellow-400 pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
)
}
return (
<div className="fixed left-8 top-1/2 -translate-y-1/2 z-40 w-[150px] h-[400px] bg-gray-900 dark:bg-gray-950 rounded-lg shadow-2xl border border-gray-700 dark:border-gray-800 overflow-hidden backdrop-blur-sm">
<div
ref={panelRef}
className="fixed z-40 w-[150px] h-[400px] bg-gray-900 dark:bg-gray-950 rounded-lg shadow-2xl border border-gray-700 dark:border-gray-800 overflow-hidden backdrop-blur-sm"
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
>
{/* 拖拽区域 */}
<div
onMouseDown={handleMouseDown}
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 cursor-grab active:cursor-grabbing"
>
<h3 className="text-sm font-bold text-white leading-tight">
{getTabTitle()}
</h3>
</div>
{/* 关闭按钮 */}
<button
onClick={handleClose}
@ -125,13 +322,6 @@ export default function RankingPanel() {
</svg>
</button>
{/* 标题 */}
<div className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700">
<h3 className="text-sm font-bold text-white leading-tight">
{getTabTitle()}
</h3>
</div>
{/* 指示器 */}
<div className="flex justify-center gap-1 py-2 bg-gray-800 border-b border-gray-700">
<button
@ -175,7 +365,6 @@ export default function RankingPanel() {
: 'bg-gray-800/50 border border-gray-700'
}`}
>
{/* 排名/奖牌 */}
<div className="flex-shrink-0 w-6 flex items-center justify-center">
{index < 3 ? (
getMedalIcon(index)
@ -185,21 +374,15 @@ export default function RankingPanel() {
</span>
)}
</div>
{/* 用户名 */}
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${
index < 3
? 'text-yellow-400'
: 'text-gray-200'
index < 3 ? 'text-yellow-400' : 'text-gray-200'
}`}
>
{user.username}
</div>
</div>
{/* 数量 */}
<div className="flex-shrink-0">
<span className={`text-xs font-bold ${
index < 3 ? 'text-yellow-400' : 'text-gray-400'
@ -215,4 +398,3 @@ export default function RankingPanel() {
</div>
)
}

View File

@ -0,0 +1,206 @@
'use client'
import { useState, useEffect } from 'react'
import { getAllPendingUploads, deleteUploadState } from '@/lib/chunk-upload'
// 格式化字节数
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
interface ChunkUploadState {
fileId: string
fileName: string
fileSize: number
chunkSize: number
totalChunks: number
uploadedChunks: Set<number>
uploadId?: string
metadata?: any
}
interface ResumeUploadModalProps {
onClose: () => void
onResume: (fileId: string, fileName: string, fileSize: number, metadata?: any) => void
onPendingChange?: (count: number) => void
}
export default function ResumeUploadModal({ onClose, onResume, onPendingChange }: ResumeUploadModalProps) {
const [pendingUploads, setPendingUploads] = useState<ChunkUploadState[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadPendingUploads()
}, [])
const loadPendingUploads = async () => {
try {
const uploads = await getAllPendingUploads()
// 将Set转换为数组以便序列化
const serializedUploads = uploads.map(upload => ({
...upload,
uploadedChunks: Array.from(upload.uploadedChunks),
}))
setPendingUploads(serializedUploads as any)
onPendingChange?.(serializedUploads.length)
if (serializedUploads.length === 0) {
onClose()
}
} catch (error) {
console.error('[ResumeUploadModal] Failed to load pending uploads:', error)
} finally {
setLoading(false)
}
}
const handleResume = (fileId: string, fileName: string, fileSize: number, metadata?: any) => {
onResume(fileId, fileName, fileSize, metadata)
onClose()
}
const handleCancel = async (fileId: string) => {
try {
await deleteUploadState(fileId)
await loadPendingUploads()
} catch (error) {
console.error('[ResumeUploadModal] Failed to cancel upload:', error)
}
}
const handleCancelAll = async () => {
try {
for (const upload of pendingUploads) {
await deleteUploadState(upload.fileId)
}
await loadPendingUploads()
if (pendingUploads.length === 0) {
onClose()
}
} catch (error) {
console.error('[ResumeUploadModal] Failed to cancel all uploads:', error)
}
}
const getProgress = (upload: ChunkUploadState) => {
const uploadedCount = Array.isArray(upload.uploadedChunks)
? upload.uploadedChunks.length
: upload.uploadedChunks.size || 0
return Math.round((uploadedCount / upload.totalChunks) * 100)
}
if (loading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">检查未完成的上传...</p>
</div>
</div>
</div>
)
}
if (pendingUploads.length === 0) {
return null
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
发现未完成的上传
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" 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>
<p className="text-gray-600 dark:text-gray-400 mb-4">
检测到 {pendingUploads.length} 个未完成的上传任务,可随时恢复继续。
<br />
<span className="text-sm text-gray-500 dark:text-gray-400">提示:您仍可在其他页面继续新增上传。</span>
</p>
<div className="space-y-3 mb-4">
{pendingUploads.map((upload) => {
const progress = getProgress(upload)
const uploadedCount = Array.isArray(upload.uploadedChunks)
? upload.uploadedChunks.length
: upload.uploadedChunks.size || 0
return (
<div
key={upload.fileId}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white truncate">
{upload.fileName}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{formatBytes(upload.fileSize)} • {uploadedCount}/{upload.totalChunks} 块已上传
</p>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{progress}%
</p>
</div>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-3">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleResume(upload.fileId, upload.fileName, upload.fileSize, (upload as any).metadata)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
继续上传
</button>
<button
onClick={() => handleCancel(upload.fileId)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
取消
</button>
</div>
</div>
)
})}
</div>
<div className="flex gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
稍后处理
</button>
<button
onClick={handleCancelAll}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
全部取消
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,107 @@
'use client'
import { useState, useEffect, useRef } from 'react'
export default function ShadowSystemIndicator() {
const [isShadowSystem, setIsShadowSystem] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const indicatorRef = useRef<HTMLDivElement>(null)
// 检测是否为影子系统
useEffect(() => {
if (typeof window !== 'undefined') {
const isShadow = window.location.port === '5320' || window.location.href.includes(':5320')
setIsShadowSystem(isShadow)
// 从 localStorage 恢复位置
const savedPosition = localStorage.getItem('shadowIndicatorPosition')
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition)
setPosition(pos)
} catch (e) {
// 默认位置:右上角
setPosition({ x: window.innerWidth - 120, y: 80 })
}
} else {
// 默认位置:右上角
setPosition({ x: window.innerWidth - 120, y: 80 })
}
}
}, [])
// 拖拽处理
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
setIsDragging(true)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
})
}
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return
let newX = e.clientX - dragStart.x
let newY = e.clientY - dragStart.y
// 限制在视窗内
if (indicatorRef.current) {
const rect = indicatorRef.current.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height
newX = Math.max(0, Math.min(newX, maxX))
newY = Math.max(0, Math.min(newY, maxY))
}
setPosition({ x: newX, y: newY })
}
const handleMouseUp = () => {
if (isDragging) {
// 保存位置到 localStorage
const newPos = { x: position.x, y: position.y }
localStorage.setItem('shadowIndicatorPosition', JSON.stringify(newPos))
}
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, dragStart, position])
if (!isShadowSystem) return null
return (
<div
ref={indicatorRef}
className="fixed z-[100] cursor-move select-none"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
cursor: isDragging ? 'grabbing' : 'grab'
}}
onMouseDown={handleMouseDown}
>
<div className="bg-gradient-to-r from-orange-500 to-red-500 text-white px-3 py-1.5 rounded-lg shadow-lg text-xs font-bold flex items-center gap-1.5">
<svg className="w-3 h-3 animate-pulse" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span>影子系统</span>
</div>
</div>
)
}

540
components/SkuEditModal.tsx Normal file
View File

@ -0,0 +1,540 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
// SKU 数据类型
interface Sku {
id: string
skuName: string
skuCode?: string
previewUrl: string
zipFileName: string
zipFilePath: string
zipFileSize: string
sortOrder: number
uploaderId: string
}
interface SkuEditModalProps {
materialId: string
isOpen: boolean
onClose: () => void
onSaved: () => void // 保存成功后的回调
}
export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: SkuEditModalProps) {
const [skus, setSkus] = useState<Sku[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingSkuId, setUploadingSkuId] = useState<string | null>(null)
const { toast, showToast } = useToast()
// 新增 SKU 表单
const [showAddForm, setShowAddForm] = useState(false)
const [newSkuName, setNewSkuName] = useState('')
const [newSkuPreviewUrl, setNewSkuPreviewUrl] = useState('')
const [newSkuFile, setNewSkuFile] = useState<{ fileName: string; filePath: string; fileSize: number } | null>(null)
const [uploadingNew, setUploadingNew] = useState(false)
const [uploadingPreview, setUploadingPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
// 加载 SKU 列表
useEffect(() => {
if (isOpen) {
fetchSkus()
}
}, [isOpen, materialId])
const fetchSkus = async () => {
try {
setLoading(true)
const response = await fetch(`/api/materials/${materialId}/skus`)
if (response.ok) {
const data = await response.json()
setSkus(data.skus || [])
}
} catch (error) {
console.error('加载 SKU 列表失败:', error)
} finally {
setLoading(false)
}
}
// 格式化文件大小
const formatFileSize = (bytes: string | number): string => {
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
if (size === 0 || isNaN(size)) return '未知'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(size) / Math.log(1024))
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
}
// 上传压缩包
const handleUploadZip = async (file: File): Promise<{ fileName: string; filePath: string; fileSize: number } | null> => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch(`/api/materials/${materialId}/skus/upload`, {
method: 'POST',
body: formData
})
if (response.ok) {
const data = await response.json()
return {
fileName: data.fileName,
filePath: data.filePath,
fileSize: data.fileSize
}
} else {
const error = await response.json()
showToast(error.error || '上传失败', { variant: 'error' })
return null
}
} catch (error) {
console.error('上传失败:', error)
showToast('上传失败,请稍后重试', { variant: 'error' })
return null
}
}
// 上传预览图到 S3
const handleUploadPreview = async (file: File): Promise<string | null> => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload/preview', {
method: 'POST',
body: formData
})
if (response.ok) {
const data = await response.json()
return data.url
} else {
const error = await response.json()
showToast(error.error || '上传预览图失败', { variant: 'error' })
return null
}
} catch (error) {
console.error('上传预览图失败:', error)
showToast('上传预览图失败,请稍后重试', { variant: 'error' })
return null
}
}
// 处理新增 SKU 的压缩包选择
const handleNewZipSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingNew(true)
const result = await handleUploadZip(file)
if (result) {
setNewSkuFile(result)
// 如果没有填写名称,用文件名(去掉扩展名)作为默认名称
if (!newSkuName) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
setNewSkuName(nameWithoutExt)
}
}
setUploadingNew(false)
}
// 处理新增 SKU 的预览图选择
const handleNewPreviewSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingPreview(true)
const url = await handleUploadPreview(file)
if (url) {
setNewSkuPreviewUrl(url)
}
setUploadingPreview(false)
}
// 添加新 SKU
const handleAddSku = async () => {
if (!newSkuName.trim()) {
showToast('请输入 SKU 名称', { variant: 'error' })
return
}
if (!newSkuPreviewUrl) {
showToast('请上传预览图', { variant: 'error' })
return
}
if (!newSkuFile) {
showToast('请上传压缩包', { variant: 'error' })
return
}
setSaving(true)
try {
const response = await fetch(`/api/materials/${materialId}/skus`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuName: newSkuName.trim(),
previewUrl: newSkuPreviewUrl,
zipFileName: newSkuFile.fileName,
zipFilePath: newSkuFile.filePath,
zipFileSize: newSkuFile.fileSize
})
})
if (response.ok) {
// 重置表单
setNewSkuName('')
setNewSkuPreviewUrl('')
setNewSkuFile(null)
setShowAddForm(false)
// 刷新列表
await fetchSkus()
} else {
const error = await response.json()
showToast(error.error || '添加失败', { variant: 'error' })
}
} catch (error) {
console.error('添加 SKU 失败:', error)
showToast('添加失败,请稍后重试', { variant: 'error' })
} finally {
setSaving(false)
}
}
// 删除 SKU
const handleDeleteSku = async (skuId: string) => {
if (!confirm('确定要删除这个 SKU 吗?')) return
try {
const response = await fetch(`/api/materials/${materialId}/skus/${skuId}`, {
method: 'DELETE'
})
if (response.ok) {
setSkus(skus.filter(sku => sku.id !== skuId))
} else {
const error = await response.json()
showToast(error.error || '删除失败', { variant: 'error' })
}
} catch (error) {
console.error('删除 SKU 失败:', error)
showToast('删除失败,请稍后重试', { variant: 'error' })
}
}
// 移动 SKU(上移/下移)
const handleMoveSku = async (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= skus.length) return
const newSkus = [...skus]
const temp = newSkus[index]
newSkus[index] = newSkus[newIndex]
newSkus[newIndex] = temp
setSkus(newSkus)
// 保存新排序
try {
await fetch(`/api/materials/${materialId}/skus/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuIds: newSkus.map(sku => sku.id)
})
})
} catch (error) {
console.error('更新排序失败:', error)
}
}
// 关闭弹窗
const handleClose = () => {
setShowAddForm(false)
setNewSkuName('')
setNewSkuPreviewUrl('')
setNewSkuFile(null)
onSaved()
onClose()
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
编辑 SKU
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<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-3 border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加 SKU
</button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="text-center py-8 text-gray-500">加载中...</div>
) : (
<div className="space-y-4">
{/* 新增 SKU 表单(一行布局,支持拖拽) */}
{showAddForm && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
{/* SKU 名称和压缩包信息 */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="text"
value={newSkuName}
onChange={(e) => setNewSkuName(e.target.value)}
placeholder="SKU 名称(如:白色款)"
className="w-72 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 flex-shrink-0"
/>
{newSkuFile && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{newSkuFile.fileName} ({formatFileSize(newSkuFile.fileSize)})
</p>
)}
</div>
{/* 右侧:预览图、压缩包、操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* 预览图(支持拖拽) */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('image/')) {
handleNewPreviewSelect({ target: { files: [file] } } as any)
}
}}
>
{newSkuPreviewUrl ? (
<div className="relative w-16 h-16 rounded overflow-hidden group">
<Image
src={newSkuPreviewUrl}
alt="预览图"
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
<button
onClick={() => previewInputRef.current?.click()}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
</div>
) : (
<button
onClick={() => previewInputRef.current?.click()}
disabled={uploadingPreview}
className="w-16 h-16 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded flex flex-col items-center justify-center transition-colors border-2 border-dashed border-gray-300 dark:border-gray-500"
>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-[10px] text-gray-500 mt-0.5">{uploadingPreview ? '上传中' : '预览图'}</span>
</button>
)}
<input
ref={previewInputRef}
type="file"
accept="image/*"
onChange={handleNewPreviewSelect}
className="hidden"
/>
</div>
{/* 压缩包(支持拖拽) */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file && ['.zip', '.rar', '.7z'].some(ext => file.name.toLowerCase().endsWith(ext))) {
handleNewZipSelect({ target: { files: [file] } } as any)
}
}}
>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploadingNew}
className="w-16 h-16 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded flex flex-col items-center justify-center transition-colors border-2 border-dashed border-gray-300 dark:border-gray-500"
>
{newSkuFile ? (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-[10px] text-gray-600 dark:text-gray-300 mt-0.5">已选择</span>
</>
) : (
<>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="text-[10px] text-gray-500 mt-0.5">{uploadingNew ? '上传中' : '压缩包'}</span>
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept=".zip,.rar,.7z"
onChange={handleNewZipSelect}
className="hidden"
/>
</div>
{/* 操作按钮 */}
<button
onClick={handleAddSku}
disabled={saving || !newSkuName || !newSkuPreviewUrl || !newSkuFile}
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{saving ? '保存中...' : '添加'}
</button>
<button
onClick={() => {
setShowAddForm(false)
setNewSkuName('')
setNewSkuPreviewUrl('')
setNewSkuFile(null)
}}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* SKU 列表 */}
{skus.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无 SKU,点击上方按钮添加
</div>
) : (
skus.map((sku, index) => (
<div
key={sku.id}
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-xl"
>
{/* 预览图 */}
<div className="w-16 h-16 flex-shrink-0 rounded overflow-hidden">
<Image
src={sku.previewUrl}
alt={sku.skuName}
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
</div>
{/* SKU 信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{sku.skuName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sku.zipFileName} · {formatFileSize(sku.zipFileSize)}
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
{/* 上移 */}
<button
onClick={() => handleMoveSku(index, 'up')}
disabled={index === 0}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
title="上移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
{/* 下移 */}
<button
onClick={() => handleMoveSku(index, 'down')}
disabled={index === skus.length - 1}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
title="下移"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 删除 */}
<button
onClick={() => handleDeleteSku(sku.id)}
className="p-1.5 text-red-400 hover:text-red-600"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))
)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={handleClose}
className="px-6 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
完成
</button>
</div>
</div>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}

303
components/SkuList.tsx Normal file
View File

@ -0,0 +1,303 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
// SKU 数据类型
interface Sku {
id: string
skuName: string
skuCode?: string
previewUrl: string
zipFileName: string
zipFilePath: string
zipFileSize: string
sortOrder: number
uploaderId: string
uploader?: {
id: string
username: string
realName?: string
}
}
interface SkuListProps {
materialId: string
canEdit: boolean // 是否可以编辑(上传者或管理员)
onEditClick: () => void // 点击编辑按钮的回调
}
export default function SkuList({ materialId, canEdit, onEditClick }: SkuListProps) {
const [skus, setSkus] = useState<Sku[]>([])
const [loading, setLoading] = useState(true)
const { toast, showToast } = useToast()
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(new Set())
const [downloadingSkuId, setDownloadingSkuId] = useState<string | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null) // 大图预览
// 加载 SKU 列表
useEffect(() => {
fetchSkus()
}, [materialId])
const fetchSkus = async () => {
try {
setLoading(true)
const response = await fetch(`/api/materials/${materialId}/skus`)
if (response.ok) {
const data = await response.json()
setSkus(data.skus || [])
}
} catch (error) {
console.error('加载 SKU 列表失败:', error)
} finally {
setLoading(false)
}
}
// 格式化文件大小
const formatFileSize = (bytes: string): string => {
const size = parseInt(bytes)
if (size === 0 || isNaN(size)) return '未知'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(size) / Math.log(1024))
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
}
// 计算已选 SKU 的总大小
const calculateTotalSize = (): string => {
let total = 0
skus.forEach(sku => {
if (selectedSkuIds.has(sku.id)) {
total += parseInt(sku.zipFileSize) || 0
}
})
return formatFileSize(total.toString())
}
// 全选/取消全选
const handleSelectAll = () => {
if (selectedSkuIds.size === skus.length) {
setSelectedSkuIds(new Set())
} else {
setSelectedSkuIds(new Set(skus.map(sku => sku.id)))
}
}
// 单选/取消单选
const handleSelectSku = (skuId: string) => {
const newSelected = new Set(selectedSkuIds)
if (newSelected.has(skuId)) {
newSelected.delete(skuId)
} else {
newSelected.add(skuId)
}
setSelectedSkuIds(newSelected)
}
// 下载单个 SKU
const handleDownloadSku = async (sku: Sku) => {
try {
setDownloadingSkuId(sku.id)
const response = await fetch(`/api/materials/${materialId}/skus/${sku.id}/download`)
if (!response.ok) {
const error = await response.json()
showToast(error.error || '下载失败', { variant: 'error' })
return
}
// 获取文件并下载
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = sku.zipFileName || `${sku.skuName}.zip`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载 SKU 失败:', error)
showToast('下载失败,请稍后重试', { variant: 'error' })
} finally {
setDownloadingSkuId(null)
}
}
// 下载已选的 SKU(逐个下载)
const handleDownloadSelected = async () => {
const selectedSkus = skus.filter(sku => selectedSkuIds.has(sku.id))
for (const sku of selectedSkus) {
await handleDownloadSku(sku)
// 每个下载之间稍微延迟,避免浏览器阻止
await new Promise(resolve => setTimeout(resolve, 500))
}
}
if (loading) {
return (
<div className="py-4 text-center text-gray-500 dark:text-gray-400 text-sm">
加载中...
</div>
)
}
if (skus.length === 0) {
return (
<div className="py-4 flex items-center justify-between">
<span className="text-gray-500 dark:text-gray-400 text-sm">暂无 SKU</span>
{canEdit && (
<button
onClick={onEditClick}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加 SKU
</button>
)}
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
SKU 列表 ({skus.length}个)
</span>
{canEdit && (
<button
onClick={onEditClick}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded-lg transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
编辑
</button>
)}
</div>
{/* 全选栏 */}
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedSkuIds.size === skus.length && skus.length > 0}
onChange={handleSelectAll}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-xs text-gray-600 dark:text-gray-300">全选</span>
</label>
{selectedSkuIds.size > 0 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
已选 {selectedSkuIds.size} 个,共 {calculateTotalSize()}
</span>
)}
</div>
{/* SKU 列表(可滚动区域) */}
<div
className="flex-1 overflow-y-auto space-y-2 pr-1"
style={{ maxHeight: 'calc(100vh - 320px)' }}
>
{skus.map(sku => (
<div
key={sku.id}
className="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
{/* 复选框 */}
<input
type="checkbox"
checked={selectedSkuIds.has(sku.id)}
onChange={() => handleSelectSku(sku.id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
/>
{/* 预览图(点击放大) */}
<div
className="w-12 h-12 flex-shrink-0 rounded overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setPreviewImage(sku.previewUrl)}
>
<Image
src={sku.previewUrl}
alt={sku.skuName}
width={48}
height={48}
className="w-full h-full object-cover"
unoptimized
/>
</div>
{/* SKU 信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{sku.skuName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(sku.zipFileSize)}
</p>
</div>
{/* 下载按钮 */}
<button
onClick={() => handleDownloadSku(sku)}
disabled={downloadingSkuId === sku.id}
className="flex-shrink-0 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{downloadingSkuId === sku.id ? '下载中...' : '下载'}
</button>
</div>
))}
</div>
{/* 底部下载按钮 */}
{selectedSkuIds.size > 0 && (
<button
onClick={handleDownloadSelected}
className="mt-3 w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
下载已选 ({selectedSkuIds.size}个)
</button>
)}
{/* 大图预览弹窗 */}
{previewImage && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh]">
<Image
src={previewImage}
alt="预览图"
width={800}
height={800}
className="max-w-full max-h-[90vh] object-contain"
unoptimized
/>
<button
onClick={() => setPreviewImage(null)}
className="absolute top-2 right-2 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,212 @@
'use client'
import { useEffect, useState } from 'react'
interface SuperAdmin {
id: string
email: string
username: string
realName: string | null
department: string
role: string
createdAt: string
}
interface SuperAdminsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
const [loading, setLoading] = useState(true)
const [count, setCount] = useState(0)
const [canAdd, setCanAdd] = useState(false)
const [addingUserId, setAddingUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
// 加载超级管理员列表
const loadSuperAdmins = async () => {
try {
const response = await fetch('/api/admin/super-admins')
if (response.ok) {
const data = await response.json()
setSuperAdmins(data.superAdmins)
setCount(data.count)
setCanAdd(data.canAdd)
} else {
showToast('加载超级管理员列表失败', 'error')
}
} catch (error) {
console.error('Load super admins error:', error)
showToast('加载超级管理员列表失败', 'error')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadSuperAdmins()
}, [])
// 添加超级管理员
const handleAddSuperAdmin = async () => {
if (!addingUserId.trim()) {
showToast('请输入用户ID', 'error')
return
}
setAdding(true)
try {
const response = await fetch('/api/admin/super-admins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: addingUserId.trim() }),
})
if (response.ok) {
showToast('超级管理员添加成功', 'success')
setAddingUserId('')
await loadSuperAdmins()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Add super admin error:', error)
showToast('添加失败', 'error')
} finally {
setAdding(false)
}
}
// 移除超级管理员
const handleRemoveSuperAdmin = async (userId: string) => {
if (!confirm('确定要移除该超级管理员权限吗?')) {
return
}
setRemovingUserId(userId)
try {
const response = await fetch(`/api/admin/super-admins?userId=${userId}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('超级管理员权限已移除', 'success')
await loadSuperAdmins()
} else {
const data = await response.json()
showToast(data.error || '移除失败', 'error')
}
} catch (error) {
console.error('Remove super admin error:', error)
showToast('移除失败', 'error')
} finally {
setRemovingUserId(null)
}
}
if (loading) {
return (
<div className="card-apple">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
加载中...
</div>
</div>
)
}
return (
<div className="card-apple">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
超级管理员管理
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
当前:{count} / 4 个超级管理员
{!canAdd && <span className="ml-2 text-orange-600 dark:text-orange-400">(已达上限)</span>}
</p>
</div>
<div className="p-6 space-y-6">
{/* 添加超级管理员 */}
{canAdd && (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
添加超级管理员
</h3>
<div className="flex gap-2">
<input
type="text"
value={addingUserId}
onChange={(e) => setAddingUserId(e.target.value)}
placeholder="输入用户ID"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAddSuperAdmin}
disabled={adding || !addingUserId.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{adding ? '添加中...' : '添加'}
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
提示:只能添加管理员角色的用户,且最多4个超级管理员
</p>
</div>
)}
{/* 超级管理员列表 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
超级管理员列表
</h3>
{superAdmins.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无超级管理员
</div>
) : (
<div className="space-y-2">
{superAdmins.map((admin) => (
<div
key={admin.id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{admin.realName || admin.username}
</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">
超级管理员
</span>
</div>
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>{admin.email}</span>
{admin.department && (
<span className="ml-2">· {admin.department}</span>
)}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
创建时间:{new Date(admin.createdAt).toLocaleString('zh-CN')}
</div>
</div>
<button
onClick={() => handleRemoveSuperAdmin(admin.id)}
disabled={removingUserId === admin.id}
className="ml-4 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 bg-red-50 dark:bg-red-900/20 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
{removingUserId === admin.id ? '移除中...' : '移除'}
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

20
components/Toast.tsx Normal file
View File

@ -0,0 +1,20 @@
'use client'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
interface ToastProps {
toast: ReturnType<typeof useToast>['toast']
}
export default function Toast({ toast }: ToastProps) {
if (!toast || !toast.show) return null
return (
<ToastBubble
message={toast.message}
variant={toast.variant}
/>
)
}

View File

@ -0,0 +1,41 @@
import React from 'react'
type ToastVariant = 'success' | 'error'
interface ToastBubbleProps {
message: string
variant?: ToastVariant
topOffsetClass?: string // e.g. 'top-24'
zIndexClass?: string // allow override when需要更高层级
}
export default function ToastBubble({
message,
variant = 'success',
topOffsetClass = 'top-24',
zIndexClass = 'z-[200]',
}: ToastBubbleProps) {
return (
<div className={`fixed ${topOffsetClass} inset-x-0 ${zIndexClass} flex justify-center pointer-events-none`}>
<div
className={`pointer-events-auto px-4 py-2 rounded-full shadow-lg border text-sm font-medium flex items-center gap-2 backdrop-blur-md ${
variant === 'success'
? 'bg-emerald-500/95 border-emerald-400 text-white'
: 'bg-rose-500/95 border-rose-400 text-white'
}`}
>
{variant === 'success' ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>{message}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,110 @@
# 更新日志弹窗组件使用说明
## 功能说明
`UpdateLogModal` 是一个符合苹果风格的更新日志弹窗组件,用于向用户展示系统的新功能、优化和修复。
## 特性
- ✅ 符合苹果设计风格(圆角、阴影、渐变)
- ✅ 自动检测用户是否已查看过当前版本
- ✅ 首次进入系统时自动弹出
- ✅ 支持深色模式
- ✅ 响应式设计
- ✅ 通俗易懂的更新说明
## 如何更新日志
### 1. 更新版本号
在 `components/UpdateLogModal.tsx` 文件中,找到 `CURRENT_VERSION` 常量:
```typescript
const CURRENT_VERSION = '1.0.0' // 更新这个版本号
```
### 2. 添加更新内容
在 `UPDATE_LOGS` 数组中添加新的更新日志:
```typescript
const UPDATE_LOGS: UpdateLog[] = [
{
version: '1.0.1', // 新版本号
date: '2025-12-07', // 更新日期
title: '系统更新', // 标题
items: [
{
type: 'feature', // 类型:'feature' | 'improvement' | 'fix'
title: '新功能标题',
description: '通俗易懂的功能描述,面向客户,避免技术术语。'
},
{
type: 'improvement',
title: '优化标题',
description: '优化说明,告诉用户这个优化能带来什么好处。'
},
{
type: 'fix',
title: '修复标题',
description: '修复说明,告诉用户修复了什么问题。'
}
]
}
]
```
### 3. 更新类型说明
- **`feature`** (新功能) - 显示 ✨ 图标,蓝色背景
- **`improvement`** (优化) - 显示 🚀 图标,绿色背景
- **`fix`** (修复) - 显示 🔧 图标,橙色背景
### 4. 编写更新说明的注意事项
✅ **好的示例:**
- "优化了视频上传和转码流程,现在可以更稳定地上传和预览视频素材了。"
- "改进了文件上传机制,即使网络不稳定也能更可靠地完成上传。"
- "修复了视频预览播放时的卡顿问题,现在播放更流畅了。"
❌ **避免的写法:**
- "修复了 S3 上传 404 错误"(技术术语)
- "优化了 FFmpeg 转码参数"(用户不关心技术细节)
- "更新了依赖包版本"(对用户无意义)
## 工作原理
1. 用户首次进入系统时,组件会检查 `localStorage` 中存储的 `update_log_viewed_version`
2. 如果存储的版本号与 `CURRENT_VERSION` 不一致,则显示更新弹窗
3. 用户点击"知道了"按钮后,将当前版本号保存到 `localStorage`
4. 下次进入系统时,如果版本号相同,则不再显示弹窗
## 手动触发更新弹窗
如果需要手动触发显示更新日志(例如在设置页面添加"查看更新日志"按钮),可以使用:
```typescript
import { showUpdateLog } from '@/components/UpdateLogModal'
// 显示当前版本的更新日志
showUpdateLog()
// 显示指定版本的更新日志
showUpdateLog('1.0.0')
```
## 样式定制
组件使用了项目中的苹果风格样式:
- `rounded-2xl` - 圆角
- `shadow-apple-lg` - 阴影
- `bg-gradient-to-br` - 渐变背景
- 支持深色模式(`dark:` 前缀)
## 注意事项
- 每次有新功能或优化时,记得更新 `CURRENT_VERSION`
- 更新说明要通俗易懂,面向最终用户
- 避免使用技术术语,用用户能理解的语言描述
- 更新日期格式:`YYYY-MM-DD`

View File

@ -3,8 +3,8 @@
import { useEffect, useState } from 'react'
// 更新日志配置 - 每次有新功能或优化时更新这个版本号
const CURRENT_VERSION = '2.2.1'
const STORAGE_KEY = 'update_log_viewed_version_v221_r1'
const CURRENT_VERSION = '2.2.32'
const STORAGE_KEY = 'update_log_viewed_version_v2232'
interface UpdateItem {
type: 'feature' | 'improvement' | 'fix' // 新功能、优化、修复
@ -22,6 +22,110 @@ interface UpdateLog {
// 更新日志内容 - 通俗易懂,面向客户
// 注意:只有经过测试验证的功能才会添加到更新记录中
const UPDATE_LOGS: UpdateLog[] = [
{
version: '2.2.32',
date: '2025-12-19',
title: '系统更新',
items: [
{
type: 'feature',
title: '标准模型 SKU 功能',
description:
'新增标准模型素材类型,支持以产品型号(SPU)为基准创建模型合集。每个标准模型可添加多个 SKU(如不同颜色、尺寸的款式),支持单选、多选或全选下载。上传时只需填写 SPU 型号和预览图,SKU 可后续添加。详情页右侧显示 SKU 列表,支持拖拽上传、排序调整,操作更便捷。'
},
{
type: 'improvement',
title: '上传超大原图自动优化',
description:
'上传预览图时,系统会自动处理超大原图(如相机拍摄的照片)。自动调整到合适大小并压缩,上传更快,加载更流畅,显示效果不变。'
}
]
},
{
version: '2.2.31',
date: '2025-12-18',
title: '系统更新',
items: [
{
type: 'fix',
title: '模特类素材支持正常下载',
description:
'修复模特类素材卡片下载按钮可点但无法下载的问题,现在会自动将模特素材的展示图打包成压缩包,老数据也会补齐可下载文件。'
},
{
type: 'fix',
title: '首页按素材类型筛选后返回不再错乱',
description:
'修复在首页选择“模特”等素材类型后进入详情再返回时,后续瀑布流会混入其他类型素材的问题,现在返回后会完整恢复原来的筛选条件和浏览位置。'
}
]
},
{
version: '2.2.3',
date: '2025-12-17',
title: '系统更新',
items: [
{
type: 'feature',
title: '新增"提交反馈"入口',
description:
'在头像下拉菜单中新增"提交反馈"入口,点击可直接跳转到钉钉文档提交功能建议、优化意见或Bug反馈,帮助我们持续改进系统。'
},
{
type: 'feature',
title: '首页筛选项可折叠收起',
description:
'首页筛选区域新增折叠功能,默认收起,点击可展开。收起时显示已选筛选数量,界面更简洁,浏览素材更专注。'
},
{
type: 'feature',
title: '新增产品类别筛选',
description: '首页新增"产品类别"筛选项,支持按书桌、书架、椅子等产品类型筛选素材,查找更精准。'
},
{
type: 'fix',
title: '详情页返回后不再跳到顶部',
description: '修复从素材详情页返回首页后自动跳到顶部的问题,现在会保持原来的浏览位置。'
},
{
type: 'fix',
title: '搜索输入不再频繁刷新',
description: '修复搜索栏输入一个字就刷新页面的问题,现在输入完成后才会触发搜索,体验更流畅。'
},
{
type: 'fix',
title: '滚动后素材预览图正常显示',
description: '修复向下滚动一段距离后部分素材卡片预览图不显示的问题,现在所有卡片都能正常加载预览图。'
}
]
},
{
version: '2.2.2',
date: '2025-12-10',
title: '系统更新',
items: [
{
type: 'fix',
title: '瀑布流返回定位更精准',
description: '修复浏览多页后进入素材详情返回会跳到错误位置或顶部的问题,返回时自动补拉数据并定位到原卡片。'
},
{
type: 'improvement',
title: '瀑布流单一模式与筛选显示优化',
description: '首页固定使用瀑布流布局,移除分页切换;“当前筛选”不再展示素材类型,筛选提示更简洁。'
},
{
type: 'feature',
title: '排序支持正/倒序切换',
description: '“最新上传 / 下载最多”均可切换正序或倒序,满足不同浏览习惯。'
},
{
type: 'improvement',
title: '默认标签改为“3D场景”',
description: '首页默认展示 3D 场景素材,方便快速浏览核心内容。'
}
]
},
{
version: '2.2.1',
date: '2025-12-09',

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
'use client'
import { useState, useRef, useEffect } from 'react'
interface VideoPlayerModalProps {
videoSrc: string
onClose: () => void
}
export default function VideoPlayerModal({ videoSrc, onClose }: VideoPlayerModalProps) {
const [isLoading, setIsLoading] = useState(true)
const [isBuffering, setIsBuffering] = useState(false)
const [loadProgress, setLoadProgress] = useState(0)
const [canPlay, setCanPlay] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
// 组件挂载时,视频开始加载
setIsLoading(true)
setCanPlay(false)
setLoadProgress(0)
}, [videoSrc])
const handleLoadStart = () => {
console.log('[VideoPlayer] 开始加载视频')
setIsLoading(true)
setCanPlay(false)
setLoadProgress(0)
}
const handleLoadedMetadata = () => {
console.log('[VideoPlayer] 视频元数据加载完成')
setIsLoading(false)
}
const handleProgress = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const video = e.currentTarget
if (video.buffered.length > 0 && video.duration > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1)
const progress = (bufferedEnd / video.duration) * 100
setLoadProgress(progress)
console.log(`[VideoPlayer] 加载进度: ${progress.toFixed(1)}%`)
}
}
const handleCanPlay = () => {
console.log('[VideoPlayer] 视频可以播放')
setCanPlay(true)
setIsLoading(false)
}
const handleCanPlayThrough = () => {
console.log('[VideoPlayer] 视频可以流畅播放')
setIsLoading(false)
setIsBuffering(false)
}
const handleWaiting = () => {
console.log('[VideoPlayer] 视频缓冲中...')
setIsBuffering(true)
}
const handlePlaying = () => {
console.log('[VideoPlayer] 视频开始播放')
setIsBuffering(false)
}
const handleError = (e: React.SyntheticEvent<HTMLVideoElement>) => {
console.error('[VideoPlayer] 视频加载错误:', e)
setIsLoading(false)
}
return (
<div
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-5xl"
style={{
maxHeight: 'min(750px, calc(100vh - 2rem))',
maxWidth: 'min(1333px, calc(100vw - 2rem))'
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="relative w-full bg-black rounded-apple shadow-apple-lg overflow-hidden"
style={{
maxHeight: 'min(750px, calc(100vh - 2rem))',
aspectRatio: '16/9',
width: '100%',
height: 'auto'
}}
>
{/* 加载状态指示器 */}
{(isLoading || isBuffering) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mb-4"></div>
<p className="text-white text-sm">
{isLoading ? '正在加载视频...' : '视频缓冲中...'}
</p>
{loadProgress > 0 && (
<div className="mt-2 w-64 bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className="bg-blue-500 h-full transition-all duration-300"
style={{ width: `${loadProgress}%` }}
/>
</div>
)}
</div>
</div>
)}
<video
ref={videoRef}
src={videoSrc}
controls
autoPlay
preload="auto"
playsInline
className="w-full h-full object-contain"
style={{
maxHeight: 'min(750px, calc(100vh - 2rem))',
width: '100%',
height: 'auto'
}}
onLoadStart={handleLoadStart}
onLoadedMetadata={handleLoadedMetadata}
onProgress={handleProgress}
onCanPlay={handleCanPlay}
onCanPlayThrough={handleCanPlayThrough}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onError={handleError}
/>
</div>
<button
className="absolute -top-3 -right-3 bg-white text-gray-800 rounded-full w-8 h-8 shadow flex items-center justify-center hover:bg-gray-100 transition-colors z-10"
onClick={onClose}
aria-label="关闭"
>
×
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,331 @@
'use client'
import Image from 'next/image'
import type { BatchFileItem } from './upload-types'
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer } from '@/lib/material-validation'
import { formatBytes } from './upload-utils'
interface BatchFileListProps {
batchFiles: BatchFileItem[]
materialType: string
categories: Array<{ id: string; name: string }>
scenes: Array<{ id: string; name: string }>
platforms: Array<{ id: string; name: string }>
productCategories: Array<{ id: string; name: string }>
renderer: string
batchPreviewDragging: Record<string, boolean>
onToggleExpand: (index: number) => void
onDelete: (index: number) => void
onUpdateProductModel: (index: number, value: string) => void
onUpdatePreviewImage: (index: number, file: File) => void
onRemovePreviewImage: (index: number) => void
onUpdateCategory: (index: number, value: string) => void
onUpdateScene: (index: number, value: string) => void
onUpdatePlatform: (index: number, value: string) => void
onUpdateProductCategory: (index: number, value: string) => void
onUpdateRenderer: (index: number, value: string) => void
onExitBatchMode: () => void
onPreviewDragEnter: (itemId: string) => void
onPreviewDragLeave: (itemId: string) => void
}
export default function BatchFileList({
batchFiles,
materialType,
categories,
scenes,
platforms,
productCategories,
renderer,
batchPreviewDragging,
onToggleExpand,
onDelete,
onUpdateProductModel,
onUpdatePreviewImage,
onRemovePreviewImage,
onUpdateCategory,
onUpdateScene,
onUpdatePlatform,
onUpdateProductCategory,
onUpdateRenderer,
onExitBatchMode,
onPreviewDragEnter,
onPreviewDragLeave,
}: BatchFileListProps) {
const handlePreviewDrop = (e: React.DragEvent, itemId: string, index: number) => {
e.preventDefault()
e.stopPropagation()
const files = Array.from(e.dataTransfer.files)
const imageFile = files.find(f => f.type.startsWith('image/'))
if (imageFile) {
onUpdatePreviewImage(index, imageFile)
}
}
const handlePreviewFileSelect = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const file = e.target.files?.[0]
if (file) {
onUpdatePreviewImage(index, file)
}
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
批量上传模式({batchFiles.length} 个文件)
</p>
<button
type="button"
onClick={onExitBatchMode}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
退出批量模式
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
{batchFiles.map((item, index) => (
<div key={item.id} className="border border-gray-200 dark:border-gray-700 rounded-apple overflow-hidden">
{/* 文件行(可点击展开/收起) */}
<div
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onClick={() => onToggleExpand(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${item.expanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{item.file.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatBytes(item.file.size)} {item.productModel && `· SPU: ${item.productModel}`}
</p>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(index)
}}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm ml-2"
>
删除
</button>
</div>
{/* 展开的配置区域 */}
{item.expanded && (
<div className="p-4 bg-white dark:bg-gray-900 space-y-4 border-t border-gray-200 dark:border-gray-700">
{/* SPU型号 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
</label>
<input
type="text"
value={item.productModel}
onChange={(e) => onUpdateProductModel(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
placeholder="例如:XY-2024-001"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 预览图片(如果需要) */}
{!PREVIEW_OPTIONAL_TYPES.includes(materialType) && materialType !== '详情页' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图片 *
</label>
{item.previewImageUrl ? (
<div className="relative w-20 h-20 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={item.previewImageUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onRemovePreviewImage(index)
}}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs"
>
×
</button>
</div>
) : (
<div
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors cursor-pointer ${
batchPreviewDragging[item.id]
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => {
e.preventDefault()
e.stopPropagation()
onPreviewDragEnter(item.id)
}}
onDragLeave={(e) => {
e.preventDefault()
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
onPreviewDragLeave(item.id)
}
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={(e) => {
handlePreviewDrop(e, item.id, index)
onPreviewDragLeave(item.id)
}}
onClick={(e) => {
e.stopPropagation()
document.getElementById(`batch-preview-${item.id}`)?.click()
}}
>
<input
type="file"
accept="image/*"
onChange={(e) => handlePreviewFileSelect(e, index)}
className="hidden"
id={`batch-preview-${item.id}`}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-sm text-gray-500 dark:text-gray-400">拖拽或点击选择图片</span>
</div>
)}
</div>
)}
{/* 使用店铺和场景类别(如果不是自动通用类型) */}
{requiresCategoryAndScene(materialType) && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
<select
value={item.categoryName}
onChange={(e) => onUpdateCategory(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
onClick={(e) => e.stopPropagation()}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
<select
value={item.sceneName}
onChange={(e) => onUpdateScene(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
onClick={(e) => e.stopPropagation()}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
</div>
</>
)}
{/* 平台分类(可选,仅在后台配置了平台时显示,标准模型不显示) */}
{platforms.length > 0 && materialType !== '标准模型' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
value={item.platformName}
onChange={(e) => onUpdatePlatform(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
onClick={(e) => e.stopPropagation()}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 品类分类(标准模型必填,其他类型可选) */}
{productCategories.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
</label>
<select
value={item.productCategoryName}
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
onClick={(e) => e.stopPropagation()}
>
<option value="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
)}
{/* 渲染器(仅3D场景显示) */}
{materialType === '3D场景' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
渲染器 *
</label>
<select
value={item.renderer || renderer}
onChange={(e) => onUpdateRenderer(index, e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
onClick={(e) => e.stopPropagation()}
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,88 @@
'use client'
import Image from 'next/image'
interface DisplayImageUploadProps {
displayImageUrl: string
isDisplayImageDragging: boolean
displayImageDropZoneRef: React.RefObject<HTMLDivElement>
onDisplayImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onDisplayImageDragEnter: (e: React.DragEvent) => void
onDisplayImageDragLeave: (e: React.DragEvent) => void
onDisplayImageDragOver: (e: React.DragEvent) => void
onDisplayImageDrop: (e: React.DragEvent) => void
}
export default function DisplayImageUpload({
displayImageUrl,
isDisplayImageDragging,
displayImageDropZoneRef,
onDisplayImageChange,
onDisplayImageDragEnter,
onDisplayImageDragLeave,
onDisplayImageDragOver,
onDisplayImageDrop,
}: DisplayImageUploadProps) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
详情页展示图片 *
</label>
<div
ref={displayImageDropZoneRef}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isDisplayImageDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={onDisplayImageDragEnter}
onDragLeave={onDisplayImageDragLeave}
onDragOver={onDisplayImageDragOver}
onDrop={onDisplayImageDrop}
>
<div className="flex items-center space-x-4">
{displayImageUrl ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={displayImageUrl}
alt="Display"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="flex-1">
<input
type="file"
onChange={onDisplayImageChange}
accept="image/*"
className="hidden"
id="displayImageInput"
/>
<button
type="button"
onClick={() => document.getElementById('displayImageInput')?.click()}
className="btn-secondary"
>
选择展示图片
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
上传PSD详情页的展示图片,或拖拽图片到此区域
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,88 @@
'use client'
import Image from 'next/image'
interface ModelImagesUploadProps {
modelImageUrls: string[]
onModelImagesChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemoveModelImage: (index: number) => void
}
export default function ModelImagesUpload({
modelImageUrls,
onModelImagesChange,
onRemoveModelImage,
}: ModelImagesUploadProps) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
模特展示图片 * (可上传多张)
</label>
<div className="border-2 border-dashed rounded-apple p-4 border-gray-300 dark:border-gray-600">
{/* 已上传的图片预览 */}
{modelImageUrls.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{modelImageUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={url}
alt={`模特图 ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
预览图
</div>
)}
</div>
<button
type="button"
onClick={() => onRemoveModelImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="flex-1">
<input
type="file"
onChange={onModelImagesChange}
accept="image/*"
multiple
className="hidden"
id="modelImagesInput"
/>
<button
type="button"
onClick={() => document.getElementById('modelImagesInput')?.click()}
className="btn-secondary"
>
{modelImageUrls.length > 0 ? '继续添加图片' : '选择图片'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
可一次选择多张图片上传
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,119 @@
'use client'
import Image from 'next/image'
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
interface PreviewImageUploadProps {
materialType: string
previewImages: File[]
previewUrls: string[]
isPreviewDragging: boolean
previewInputRef: React.RefObject<HTMLInputElement>
previewDropZoneRef: React.RefObject<HTMLDivElement>
onPreviewChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onPreviewDragEnter: (e: React.DragEvent) => void
onPreviewDragLeave: (e: React.DragEvent) => void
onPreviewDragOver: (e: React.DragEvent) => void
onPreviewDrop: (e: React.DragEvent) => void
onRemovePreviewImage: (index: number) => void
}
export default function PreviewImageUpload({
materialType,
previewImages,
previewUrls,
isPreviewDragging,
previewInputRef,
previewDropZoneRef,
onPreviewChange,
onPreviewDragEnter,
onPreviewDragLeave,
onPreviewDragOver,
onPreviewDrop,
onRemovePreviewImage,
}: PreviewImageUploadProps) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图片
{PREVIEW_OPTIONAL_TYPES.includes(materialType) ? '(可选)' : ' *'}
</label>
<div
ref={previewDropZoneRef}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={onPreviewDragEnter}
onDragLeave={onPreviewDragLeave}
onDragOver={onPreviewDragOver}
onDrop={onPreviewDrop}
>
{/* 已上传的图片预览 */}
{previewUrls.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{previewUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={url}
alt={`预览图 ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
主预览图
</div>
)}
</div>
<button
type="button"
onClick={() => onRemovePreviewImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="flex-1">
<input
ref={previewInputRef}
type="file"
onChange={onPreviewChange}
accept="image/*"
multiple
className="hidden"
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
className="btn-secondary"
>
{previewImages.length > 0 ? '继续添加图片' : '选择图片'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为主预览图
</p>
</div>
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More