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

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

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

184 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { readFile, access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ status: 400 }
)
}
const material = await prisma.material.findUnique({
where: { id },
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 只允许视频和音频类型
const isVideo = material.materialType === '视频' || material.materialKind === 'VIDEO'
const isAudio = material.materialType === '音频' || material.materialKind === 'AUDIO'
if (!isVideo && !isAudio) {
return NextResponse.json(
{ error: '此API仅支持视频和音频类型素材' },
{ status: 403 }
)
}
if (!material.zipFilePath) {
return NextResponse.json(
{ error: '未找到素材文件,请重新上传' },
{ status: 404 }
)
}
// 根据文件扩展名确定Content-Type
const originalExt = path.extname(material.zipFileName || material.zipFilePath || '').toLowerCase()
const videoTypes: { [key: string]: string } = {
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.webm': 'video/webm',
}
const audioTypes: { [key: string]: string } = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.wma': 'audio/x-ms-wma',
}
const defaultType = isAudio ? 'audio/mpeg' : 'video/mp4'
const contentType = videoTypes[originalExt] || audioTypes[originalExt] || defaultType
const range = req.headers.get('range')
if (isS3Url(material.zipFilePath)) {
const s3Object = await getS3ObjectStream(material.zipFilePath, range)
const bodyStream = toWebStream(s3Object.body)
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
'Cache-Control': 'public, max-age=3600',
// CORS headers for audio visualization
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Range',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
}
if (range && s3Object.contentRange) {
headers['Content-Range'] = s3Object.contentRange
}
if (!range && s3Object.contentLength) {
headers['Content-Length'] = s3Object.contentLength.toString()
}
const status = range ? 206 : 200
return new NextResponse(bodyStream as any, {
status,
headers,
})
}
// Legacy local file support
let filePath = material.zipFilePath
if (!path.isAbsolute(filePath)) {
filePath = path.join(process.cwd(), filePath)
}
console.log('Streaming legacy video file:', filePath)
try {
await access(filePath)
} catch (error) {
console.error('File not found:', filePath)
return NextResponse.json(
{ error: '文件不存在,可能已被删除' },
{ status: 404 }
)
}
const fileBuffer = await readFile(filePath)
// CORS headers for audio visualization
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Range',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
}
if (range) {
const fileSize = fileBuffer.length
const parts = range.replace(/bytes=/, '').split('-')
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
const chunkSize = end - start + 1
const chunk = fileBuffer.slice(start, end + 1)
return new NextResponse(chunk as any, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Cache-Control': 'public, max-age=3600',
...corsHeaders,
},
})
}
return new NextResponse(fileBuffer as any, {
headers: {
'Content-Type': contentType,
'Content-Length': fileBuffer.length.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=3600',
...corsHeaders,
},
})
} catch (error) {
console.error('Stream error:', error)
return NextResponse.json(
{ error: '流式传输失败: ' + (error instanceof Error ? error.message : '未知错误') },
{ status: 500 }
)
}
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof (body as any).transformToWebStream === 'function') {
return (body as any).transformToWebStream()
}
if (body instanceof Readable) {
return Readable.toWeb(body)
}
return body as ReadableStream<any>
}