修复 AudioVisualizer 音频可视化组件:解决 'already connected' 错误,使用全局 WeakMap 跟踪初始化状态;修复 Dockerfile 构建依赖问题
This commit is contained in:
parent
29115dddb8
commit
8bf309e996
@ -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
8
.gitignore
vendored
@ -36,3 +36,11 @@ next-env.d.ts
|
||||
# Uploads
|
||||
/uploads
|
||||
|
||||
# 忽略的项目目录(不属于本项目)
|
||||
/flnxi_sucai
|
||||
/gongxiangp2
|
||||
/frp_*
|
||||
|
||||
# Docker 缓存
|
||||
/.docker-cache
|
||||
|
||||
|
||||
47
Dockerfile
47
Dockerfile
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
89
app/api/admin/department-permissions/batch/route.ts
Normal file
89
app/api/admin/department-permissions/batch/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
75
app/api/admin/department-permissions/copy/route.ts
Normal file
75
app/api/admin/department-permissions/copy/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
117
app/api/admin/department-permissions/link/route.ts
Normal file
117
app/api/admin/department-permissions/link/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
139
app/api/admin/department-permissions/route.ts
Normal file
139
app/api/admin/department-permissions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
48
app/api/admin/materials/batch-delete/route.ts
Normal file
48
app/api/admin/materials/batch-delete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
185
app/api/admin/super-admins/route.ts
Normal file
185
app/api/admin/super-admins/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
241
app/api/admin/system-status/route.ts
Normal file
241
app/api/admin/system-status/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
93
app/api/admin/trash/route.ts
Normal file
93
app/api/admin/trash/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,14 @@
|
||||
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({
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,69 +1,92 @@
|
||||
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: {
|
||||
managedDepartment: departmentName,
|
||||
role: 'ADMIN' // 设置为管理员角色
|
||||
role: 'ADMIN'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: '部门管理员设置成功',
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// 取消部门管理员
|
||||
@ -72,15 +95,17 @@ export async function DELETE(req: NextRequest) {
|
||||
data: {
|
||||
managedDepartment: null
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: '已取消部门管理员',
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
username: user.username,
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
188
app/api/admin/video-transcode-stats/route.ts
Normal file
188
app/api/admin/video-transcode-stats/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }]
|
||||
parts: [{ text: msg.content }],
|
||||
};
|
||||
}
|
||||
} else if (Array.isArray(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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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()
|
||||
const { email, username, department, password } = await request.json();
|
||||
|
||||
// Validation
|
||||
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)
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
// 创建用户
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
})
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (latestDanmaku.length > 0) {
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(departments)
|
||||
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 })
|
||||
} catch (error) {
|
||||
console.error('Error creating department:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '创建部门失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a department (admin only)
|
||||
export async function DELETE(req: NextRequest) {
|
||||
// 自动创建默认权限配置
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限' },
|
||||
{ status: 403 }
|
||||
)
|
||||
await createDefaultPermissionsForDepartment(name);
|
||||
} catch (error) {
|
||||
console.error('Error creating default permissions for department:', error);
|
||||
// 不阻止部门创建,只记录错误
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
return NextResponse.json(department, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating department:', error);
|
||||
return NextResponse.json({ error: '创建部门失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/departments - 删除部门
|
||||
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 }
|
||||
)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
71
app/api/materials/[id]/retry-transcode/route.ts
Normal file
71
app/api/materials/[id]/retry-transcode/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
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,
|
||||
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 }
|
||||
)
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 返回素材信息(转换BigInt为字符串)
|
||||
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,
|
||||
user: {
|
||||
username: material.user.username
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('获取素材失败:', error)
|
||||
// 权限检查:如果不是分享链接访问,检查查看权限
|
||||
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: 500 }
|
||||
)
|
||||
{ error: '无权限查看该素材' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录用户且不是分享链接,不允许查看
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 类型断言,确保 user 字段存在
|
||||
const materialWithUser = material as any;
|
||||
|
||||
return NextResponse.json({
|
||||
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: materialWithUser.user?.username || '',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取素材失败:', error);
|
||||
return NextResponse.json({ error: '获取素材失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,60 +206,96 @@ 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 material = await prisma.material.findUnique({
|
||||
where: { id: params.id }
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
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 updatedMaterial = await prisma.material.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
const {
|
||||
productModel,
|
||||
categoryName,
|
||||
sceneName,
|
||||
platformName,
|
||||
productCategoryName,
|
||||
materialType,
|
||||
renderer,
|
||||
authorName,
|
||||
updatedAt: new Date()
|
||||
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 },
|
||||
});
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查权限:素材所有者、普通管理员或超级管理员都可以编辑
|
||||
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: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
app/api/materials/[id]/skus/[skuId]/download/route.ts
Normal file
71
app/api/materials/[id]/skus/[skuId]/download/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
162
app/api/materials/[id]/skus/[skuId]/route.ts
Normal file
162
app/api/materials/[id]/skus/[skuId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
63
app/api/materials/[id]/skus/reorder/route.ts
Normal file
63
app/api/materials/[id]/skus/reorder/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
110
app/api/materials/[id]/skus/route.ts
Normal file
110
app/api/materials/[id]/skus/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
101
app/api/materials/[id]/skus/upload/route.ts
Normal file
101
app/api/materials/[id]/skus/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
// 动态打包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 (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);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount === 0) {
|
||||
console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id);
|
||||
return NextResponse.json(
|
||||
{ error: '该素材没有可下载的文件(可能只包含图片)' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const filenameParts = [
|
||||
material.productModel || 'unknown',
|
||||
material.categoryName || '',
|
||||
material.sceneName || '',
|
||||
material.productCategoryName || '',
|
||||
'展示图合集',
|
||||
].filter(Boolean);
|
||||
|
||||
const filename = `${filenameParts.join('_')}.zip`;
|
||||
|
||||
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 }
|
||||
})
|
||||
select: { canDownload: true },
|
||||
});
|
||||
|
||||
if (!user || !user.canDownload) {
|
||||
return NextResponse.json(
|
||||
{ error: '您的下载权限已被限制,请联系管理员' },
|
||||
{ status: 403 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
const { searchParams } = new URL(request.url);
|
||||
const materialId = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少素材ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (!materialId) {
|
||||
return NextResponse.json({ error: '缺少素材ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
// 查询素材
|
||||
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: 404 }
|
||||
)
|
||||
{ error: '无权限下载该素材' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 增加下载次数
|
||||
// 增加下载次数(仅非分享链接时)
|
||||
if (!isShare) {
|
||||
try {
|
||||
await prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read file from local storage
|
||||
// 确保使用绝对路径
|
||||
let filePath = material.zipFilePath
|
||||
// 检查是否有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)
|
||||
filePath = path.join(process.cwd(), filePath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Attempting to download file:', 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)
|
||||
// 获取文件大小
|
||||
const fileStats = await stat(filePath);
|
||||
const fileSize = fileStats.size;
|
||||
|
||||
// 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名
|
||||
// 获取原文件扩展名
|
||||
const originalExt = path.extname(material.zipFileName) // 例如 .zip 或 .7z
|
||||
const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}`
|
||||
// 确定文件扩展名和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}` : '');
|
||||
|
||||
console.log('Download filename:', newFileName)
|
||||
// 生成下载文件名
|
||||
const filenameParts = [
|
||||
material.productModel || 'unknown',
|
||||
material.categoryName || '',
|
||||
material.sceneName || '',
|
||||
material.productCategoryName || '',
|
||||
'源文件',
|
||||
].filter(Boolean);
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(newFileName)}"`,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
78
app/api/materials/favorite/route.ts
Normal file
78
app/api/materials/favorite/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
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({
|
||||
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({
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
app/api/materials/restore/route.ts
Normal file
62
app/api/materials/restore/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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,13 +140,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 检查收藏
|
||||
try {
|
||||
const favorite = await prisma.materialFavorite.findUnique({
|
||||
const favorite = await prisma.materialFavorite.findFirst({
|
||||
where: {
|
||||
materialId_userId: {
|
||||
materialId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
isFavorited = !!favorite;
|
||||
} catch (e: any) {
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
} 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
...(sortBy !== 'comprehensive' ? {
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})).map(m => ({
|
||||
} : {}),
|
||||
}));
|
||||
|
||||
// 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,11 +564,30 @@ export async function GET(request: NextRequest) {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
...(sortBy !== 'comprehensive' ? {
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})).map(m => ({
|
||||
} : {}),
|
||||
}));
|
||||
|
||||
// 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,
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
// 去重
|
||||
|
||||
@ -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) {
|
||||
// 解析 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 { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
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 }
|
||||
)
|
||||
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()
|
||||
}
|
||||
zipFileSize: material.zipFileSize.toString(),
|
||||
displayImages: parseDisplayImages(material.displayImages || null),
|
||||
isLiked,
|
||||
isFavorited,
|
||||
};
|
||||
|
||||
return NextResponse.json(materialData)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
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,
|
||||
},
|
||||
@ -16,6 +27,23 @@ export async function GET() {
|
||||
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,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 按月份统计
|
||||
const monthMap = new Map<string, number>();
|
||||
|
||||
198
app/api/materials/transcode/route.ts
Normal file
198
app/api/materials/transcode/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 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;
|
||||
|
||||
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 }
|
||||
)
|
||||
// 使用统一的验证逻辑
|
||||
// 基础必填字段验证
|
||||
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
|
||||
// 检查权限:素材所有者、普通管理员或超级管理员都可以编辑
|
||||
const user = session.user as any;
|
||||
const userId = user.id;
|
||||
const userRole = user.role;
|
||||
|
||||
// If new preview image is uploaded
|
||||
// 检查是否是超级管理员
|
||||
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({
|
||||
where: { id },
|
||||
data: {
|
||||
// 更新素材(包含所有字段,确保数据不丢失)
|
||||
const updateData: any = {
|
||||
previewUrl,
|
||||
productModel,
|
||||
categoryName,
|
||||
sceneName,
|
||||
// 使用统一的验证逻辑判断字段是否需要更新
|
||||
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,
|
||||
renderer,
|
||||
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: 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
app/api/materials/upload/chunk/route.ts
Normal file
71
app/api/materials/upload/chunk/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
266
app/api/materials/upload/cleanup/route.ts
Normal file
266
app/api/materials/upload/cleanup/route.ts
Normal 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]
|
||||
}
|
||||
|
||||
114
app/api/materials/upload/complete/route.ts
Normal file
114
app/api/materials/upload/complete/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
61
app/api/materials/upload/init/route.ts
Normal file
61
app/api/materials/upload/init/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (materialType !== '模特' && !zipFile) {
|
||||
console.log('[Upload] Validation failed - missing zip file')
|
||||
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
|
||||
return NextResponse.json(
|
||||
{ error: '请填写所有必填字段(店铺、场景)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件)
|
||||
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}`)
|
||||
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);
|
||||
}
|
||||
|
||||
// 处理其他预览图(作为展示图)- 并行处理以提高速度
|
||||
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: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` },
|
||||
{ error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` },
|
||||
{ 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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
// 并行处理所有预览图
|
||||
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;
|
||||
});
|
||||
|
||||
// 切割图片(按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)
|
||||
// 等待所有预览图上传完成
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
displayImages.push(...urls);
|
||||
console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`);
|
||||
}
|
||||
|
||||
// 如果没有预览图,使用第一张展示图片作为预览图
|
||||
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 }
|
||||
)
|
||||
// 处理展示图片(详情页)
|
||||
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);
|
||||
}
|
||||
|
||||
// Handle Model display images (模特类型的多图处理)
|
||||
if (materialType === '模特' && modelImages.length > 0) {
|
||||
console.log(`[Upload] Processing ${modelImages.length} model images...`)
|
||||
try {
|
||||
// 处理每张模特图片
|
||||
// 处理模特图片
|
||||
if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) {
|
||||
for (let i = 0; i < modelImages.length; i++) {
|
||||
const modelImage = modelImages[i]
|
||||
console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`)
|
||||
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);
|
||||
|
||||
// 读取图片
|
||||
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`)
|
||||
|
||||
// 上传每个切片到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)
|
||||
if (i === 0 && !previewUrl) {
|
||||
previewUrl = url;
|
||||
}
|
||||
console.log(`[Upload] Model image ${i + 1} uploaded:`, url);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用第一张图片作为预览图
|
||||
if (displayImages.length > 0) {
|
||||
previewUrl = displayImages[0]
|
||||
console.log('[Upload] Using first model image as preview:', previewUrl)
|
||||
// 处理通用展示图(反编译:买家秀/小红书/主图模版/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;
|
||||
}
|
||||
} catch (modelImageError) {
|
||||
console.error('[Upload] Model image processing error:', modelImageError)
|
||||
return NextResponse.json(
|
||||
{ error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
console.log(`[Upload] Display image ${i + 1} uploaded:`, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Save zip/PSD file locally (if exists - 模特类型可以没有素材文件)
|
||||
let zipFileName = ''
|
||||
let zipFilePath = ''
|
||||
let zipFileSize = BigInt(0)
|
||||
// 处理素材文件(主文件:3D场景/PSD/视频源等)
|
||||
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...')
|
||||
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 {
|
||||
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)`)
|
||||
// 使用分片合并后的服务器侧文件
|
||||
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);
|
||||
}
|
||||
|
||||
// Create material record
|
||||
console.log('[Upload] Creating database record...')
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 音频素材:如果没有预览图,使用占位符
|
||||
if (materialType === '音乐' && (!previewUrl || previewUrl.trim() === '')) {
|
||||
previewUrl = AUDIO_PLACEHOLDER
|
||||
console.log('[Upload] Audio material without cover image, using audio placeholder')
|
||||
}
|
||||
|
||||
// 创建数据库记录(包含所有字段,确保数据不丢失)
|
||||
// 对于视频类型,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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
app/api/materials/viewable-types/route.ts
Normal file
27
app/api/materials/viewable-types/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
127
app/api/network-diagnose/route.ts
Normal file
127
app/api/network-diagnose/route.ts
Normal 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(/\/$/, '')}`;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
// 用户在内网: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({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '网络状态检测失败'
|
||||
}, { status: 500 })
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
22
app/api/network/test-download/route.ts
Normal file
22
app/api/network/test-download/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
28
app/api/network/test-storage/route.ts
Normal file
28
app/api/network/test-storage/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
25
app/api/network/test-upload/route.ts
Normal file
25
app/api/network/test-upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
app/api/product-categories/route.ts
Normal file
145
app/api/product-categories/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
148
app/api/proxy-media/route.ts
Normal file
148
app/api/proxy-media/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUsers = await Promise.all(
|
||||
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,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const likeUsers = await Promise.all(
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }> = [];
|
||||
|
||||
// 提取原始名称
|
||||
function extractOriginalName(filename: string): string {
|
||||
const parts = filename.split('-');
|
||||
if (parts.length >= 3) {
|
||||
const namePart = parts.slice(2).join('-');
|
||||
return namePart.replace(/\.(zip|rar|7z)$/i, '');
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
// 匹配文件
|
||||
interface Match {
|
||||
previewFile: string | null;
|
||||
materialFile: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
|
||||
const matches: Match[] = [];
|
||||
|
||||
for (const materialFile of materialFiles) {
|
||||
const materialTimestamp = extractTimestamp(materialFile);
|
||||
for (const material of materials) {
|
||||
const materialTimestamp = extractTimestamp(material);
|
||||
if (!materialTimestamp) continue;
|
||||
|
||||
const matchedPreview = previewFiles.find(previewFile => {
|
||||
const previewTimestamp = extractTimestamp(previewFile);
|
||||
// 查找时间戳相近的预览图(允许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: materialFile,
|
||||
timestamp: materialTimestamp
|
||||
materialFile: material,
|
||||
timestamp: materialTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const output: string[] = [];
|
||||
// 从文件名提取产品名称
|
||||
function extractProductName(filename: string): string {
|
||||
const parts = filename.split('-');
|
||||
if (parts.length >= 3) {
|
||||
return parts.slice(2).join('-').replace(/\.(zip|rar|7z)$/i, '');
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
output.push('🚀 开始数据恢复...\n');
|
||||
|
||||
// 创建管理员用户
|
||||
output.push('📝 步骤1: 创建管理员用户');
|
||||
let admin;
|
||||
// POST - 执行数据恢复
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const hashedPassword = hashPassword(ADMIN_PASSWORD);
|
||||
admin = await prisma.user.create({
|
||||
let adminUser;
|
||||
const logs: string[] = [];
|
||||
|
||||
logs.push('🚀 开始数据恢复...\n');
|
||||
|
||||
// 步骤1: 创建管理员用户
|
||||
logs.push('📝 步骤1: 创建管理员用户');
|
||||
try {
|
||||
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} 个预览图`);
|
||||
// 步骤3: 获取S3预览图列表
|
||||
logs.push('\n📝 步骤3: 获取S3预览图列表');
|
||||
const s3Previews = await fetchS3PreviewList();
|
||||
logs.push(`✅ 从S3找到 ${s3Previews.length} 个预览图`);
|
||||
|
||||
// 获取本地素材文件
|
||||
output.push('\n📝 步骤4: 扫描本地素材文件');
|
||||
const materialFiles = getLocalMaterialFiles(UPLOADS_DIR);
|
||||
output.push(`✅ 从本地找到 ${materialFiles.length} 个素材文件`);
|
||||
// 步骤4: 扫描本地素材文件
|
||||
logs.push('\n📝 步骤4: 扫描本地素材文件');
|
||||
const localMaterials = scanLocalMaterials(UPLOADS_DIR);
|
||||
logs.push(`✅ 从本地找到 ${localMaterials.length} 个素材文件`);
|
||||
|
||||
// 匹配文件
|
||||
output.push('\n📝 步骤5: 匹配预览图和素材文件');
|
||||
const matches = matchFiles(previewFiles, materialFiles);
|
||||
// 步骤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} 个无预览图)`);
|
||||
logs.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
|
||||
|
||||
// 恢复素材数据
|
||||
output.push('\n📝 步骤6: 恢复素材数据到数据库');
|
||||
const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads';
|
||||
// 步骤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、分类、场景等信息`);
|
||||
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: output.join('\n'),
|
||||
stats: { successCount, failCount, total: matches.length }
|
||||
message: logs.join('\n'),
|
||||
stats: {
|
||||
successCount,
|
||||
failCount,
|
||||
total: matches.length,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error.message
|
||||
}, { status: 500 });
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
const where: any = { sceneName: scene.name };
|
||||
if (category) {
|
||||
where.categoryName = category;
|
||||
}
|
||||
|
||||
// 如果选择了店铺,只统计该店铺下的场景数量
|
||||
if (categoryName) {
|
||||
whereCondition.categoryName = categoryName
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,49 +1,55 @@
|
||||
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,
|
||||
@ -54,48 +60,47 @@ export async function PUT(request: NextRequest) {
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
app/api/upload/preview/route.ts
Normal file
50
app/api/upload/preview/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
100
app/api/uploads/[...path]/route.ts
Normal file
100
app/api/uploads/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
86
app/api/user/avatar/route.ts
Normal file
86
app/api/user/avatar/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
136
app/api/user/favorites/route.ts
Normal file
136
app/api/user/favorites/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
169
app/api/user/materials/route.ts
Normal file
169
app/api/user/materials/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/user/profile/route.ts
Normal file
42
app/api/user/profile/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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('授权失败:缺少授权码')
|
||||
|
||||
@ -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,16 +134,57 @@ 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)
|
||||
|
||||
try {
|
||||
@ -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">
|
||||
素材类型 *
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1596
app/page.tsx
1596
app/page.tsx
File diff suppressed because it is too large
Load Diff
556
app/profile/page.tsx
Normal file
556
app/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,12 +334,20 @@ 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宽度) */}
|
||||
{/* 左侧:预览图(固定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%' }}>
|
||||
@ -191,65 +363,232 @@ export default function SharePage() {
|
||||
</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={material.previewUrl}
|
||||
alt={material.productModel}
|
||||
width={790}
|
||||
height={1000}
|
||||
unoptimized
|
||||
className="w-full h-auto object-contain"
|
||||
style={{ display: 'block', maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 标准模型类型:显示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-6 space-y-6">
|
||||
<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-3">
|
||||
<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-xs font-medium">
|
||||
<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-xs font-medium">
|
||||
<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>
|
||||
<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">
|
||||
{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-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>
|
||||
<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-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>
|
||||
<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-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>
|
||||
<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-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>
|
||||
<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>
|
||||
<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>
|
||||
<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-xs text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
74
app/uploads/[...path]/route.ts
Normal file
74
app/uploads/[...path]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
436
components/AudioVisualizer.tsx
Normal file
436
components/AudioVisualizer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
526
components/DepartmentPermissionsTab.tsx
Normal file
526
components/DepartmentPermissionsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
渲染器 *
|
||||
|
||||
@ -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"
|
||||
>
|
||||
{/* 圆形头像 */}
|
||||
{(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
|
||||
@ -236,10 +293,25 @@ export default function Header({ session }: HeaderProps) {
|
||||
</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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
799
components/NetworkDiagnoseModal.tsx
Normal file
799
components/NetworkDiagnoseModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
670
components/NetworkDiagnoseModal.tsx.backup
Normal file
670
components/NetworkDiagnoseModal.tsx.backup
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
206
components/ResumeUploadModal.tsx
Normal file
206
components/ResumeUploadModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
107
components/ShadowSystemIndicator.tsx
Normal file
107
components/ShadowSystemIndicator.tsx
Normal 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
540
components/SkuEditModal.tsx
Normal 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
303
components/SkuList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
components/SuperAdminsTab.tsx
Normal file
212
components/SuperAdminsTab.tsx
Normal 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
20
components/Toast.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
41
components/ToastBubble.tsx
Normal file
41
components/ToastBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
110
components/UpdateLogModal.README.md
Normal file
110
components/UpdateLogModal.README.md
Normal 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`
|
||||
|
||||
@ -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
149
components/VideoPlayerModal.tsx
Normal file
149
components/VideoPlayerModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
331
components/upload/BatchFileList.tsx
Normal file
331
components/upload/BatchFileList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
88
components/upload/DisplayImageUpload.tsx
Normal file
88
components/upload/DisplayImageUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
88
components/upload/ModelImagesUpload.tsx
Normal file
88
components/upload/ModelImagesUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
119
components/upload/PreviewImageUpload.tsx
Normal file
119
components/upload/PreviewImageUpload.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user