fix: 修复批量上传和视频扩展名问题

主要修复:

- 修复批量上传闭包陷阱,防止文件丢失

- 添加 fileName 参数传递原始文件名,保留正确扩展名

- 添加取消检查回调,改进批量上传取消功能

其他改进:

- 优化搜索功能和管理后台

- 添加 FRP 故障诊断文档

- 更新环境验证和网络检测
This commit is contained in:
chenpei 2026-02-03 11:12:50 +08:00
parent 4e89b57798
commit 752b7bf999
36 changed files with 2570 additions and 324 deletions

View File

@ -66,10 +66,40 @@ npm run lint # ESLint
### Redis 配置
⚠️ **【重要】Redis 连接依赖 Docker 网络配置**
应用容器**必须**连接到 `youyidian-3d_app-network` 网络才能访问 Redis。如果 Redis 连接失败,会导致:
- 上传分片无法保存,上传到 100% 后循环重传
- 会话无法持久化,用户频繁掉线
**正确的部署命令必须包含 `--network` 参数:**
```bash
docker run -d --name youyidian-3d-material \
--network youyidian-3d_app-network \ # ⚠️ 必须指定网络!
-p 4320:3000 \
...
```
**诊断 Redis 连接问题:**
```bash
# 1. 检查容器是否在正确网络
docker network inspect youyidian-3d_app-network | grep youyidian-3d-material
# 2. 检查 Redis 连接
docker exec youyidian-3d-material sh -c "ping -c 2 gongxiangpan-redis"
# 3. 检查容器日志中的 Redis 状态
docker logs youyidian-3d-material 2>&1 | grep -i redis | tail -5
# 4. 手动连接到网络(如果遗漏)
docker network connect youyidian-3d_app-network youyidian-3d-material
docker restart youyidian-3d-material
```
**生产环境 Redis:**
- 容器名称:`gongxiangpan-redis`
- 连接地址:`redis://gongxiangpan-redis:6379`(Docker 网络内)
- 环境变量:`USE_REDIS=true`
- 环境变量:`USE_REDIS=true`、`REDIS_URL=redis://gongxiangpan-redis:6379`
- 会话 TTL:4 小时(14400 秒)
- 内存限制:128MB(maxmemory-policy: allkeys-lru)
- 无需密码
@ -529,35 +559,6 @@ catch (error) {
- 优化缓存策略(TTL、上限控制)
- 流式处理大文件,避免全量加载
**网络:**
- 优化传输效率(动态分片、并发控制)
- 减少请求次数(批量操作、合并上传)
- 数据压缩(WebP、Gzip)
- CDN 加速、资源预加载
**数据库:**
- 避免 N+1 查询
- 合理使用索引
- 分页加载大数据集
- 连接池管理
**前端渲染:**
- 虚拟列表处理长数据
- 懒加载图片和组件
- 防抖节流高频事件
- 避免不必要的重渲染
**文件处理:**
- 大文件分片上传
- 图片压缩和切片
- 视频转码和分段
- 异步处理耗时任务
**算法:**
- 关注时间复杂度
- 避免嵌套循环
- 使用 Set/Map 优化查找
### 🔒 数据安全
- 绝不删除重要数据目录

View File

@ -121,7 +121,8 @@ export default function AdminPage() {
const [currentMaterialsPage, setCurrentMaterialsPage] = useState(1)
const [hasMoreMaterials, setHasMoreMaterials] = useState(true)
const [isLoadingMoreMaterials, setIsLoadingMoreMaterials] = useState(false)
const materialsPageSize = 20
const [totalMaterialsCount, setTotalMaterialsCount] = useState(0) // 素材总数(用于显示)
const materialsPageSize = 50 // 增大分页大小,减少请求次数
// 用户管理相关状态
const [userSearchTerm, setUserSearchTerm] = useState('')
@ -161,6 +162,12 @@ export default function AdminPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, activeTab, showRecycleBin])
// 切换回收站时清空选中状态和搜索词
useEffect(() => {
setSelectedMaterials(new Set())
setMaterialSearchTerm('') // 清空搜索词,避免状态混乱
}, [showRecycleBin])
const fetchData = useCallback(async () => {
setLoading(true)
try {
@ -180,6 +187,10 @@ export default function AdminPage() {
setDisplayedMaterialsCount(materialsData.length)
setCurrentMaterialsPage(1)
setHasMoreMaterials(materialsData.length >= materialsPageSize)
// 保存总数(用于显示)
if (data.totalCount !== undefined) {
setTotalMaterialsCount(data.totalCount)
}
}
} else if (activeTab === 'users') {
const response = await fetch('/api/admin/users')
@ -224,27 +235,6 @@ export default function AdminPage() {
}
}, [])
// 素材搜索函数
const searchMaterials = useCallback(async (searchTerm: string) => {
try {
const hasSearch = searchTerm.trim().length > 0
const url = showRecycleBin
? `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}&deleted=true${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
: `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
const materialsData = data.materials || data
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
setCurrentMaterialsPage(1)
setHasMoreMaterials(hasSearch ? false : materialsData.length >= materialsPageSize)
}
} catch (error) {
console.error('搜索素材失败:', error)
}
}, [showRecycleBin, materialsPageSize])
// 加载更多素材
const loadMoreMaterials = useCallback(async () => {
if (isLoadingMoreMaterials || !hasMoreMaterials) return
@ -263,8 +253,14 @@ export default function AdminPage() {
const newMaterials = data.materials || data
if (newMaterials.length > 0) {
setMaterials(prev => [...prev, ...newMaterials])
setDisplayedMaterialsCount(prev => prev + newMaterials.length)
// 去重:过滤掉已经存在的素材,并同步更新 displayedMaterialsCount
setMaterials(prev => {
const existingIds = new Set(prev.map(m => m.id))
const uniqueNewMaterials = newMaterials.filter((m: any) => !existingIds.has(m.id))
// 同步更新 displayedMaterialsCount(使用实际去重后的数量)
setDisplayedMaterialsCount(prev.length + uniqueNewMaterials.length)
return [...prev, ...uniqueNewMaterials]
})
setCurrentMaterialsPage(nextPage)
setHasMoreMaterials(newMaterials.length >= materialsPageSize)
} else {
@ -432,12 +428,15 @@ export default function AdminPage() {
materialSearchTerm={materialSearchTerm}
setMaterialSearchTerm={setMaterialSearchTerm}
displayedMaterialsCount={displayedMaterialsCount}
setDisplayedMaterialsCount={setDisplayedMaterialsCount}
hasMoreMaterials={hasMoreMaterials}
setHasMoreMaterials={setHasMoreMaterials}
isLoadingMoreMaterials={isLoadingMoreMaterials}
showToast={showToast}
searchMaterials={searchMaterials}
loadMoreMaterials={loadMoreMaterials}
fetchData={fetchData}
materialsPageSize={materialsPageSize}
totalMaterialsCount={totalMaterialsCount}
/>
)}

View File

@ -2,36 +2,138 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { deleteFiles } from '@/lib/file-cleanup'
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'
// 批量删除素材(软删除或永久删除)
// UUID 格式校验正则
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* 批量删除素材(软删除或永久删除)
*
* 改进点:
* 1. 使用 isSystemAdmin() 统一权限校验
* 2. 永久删除时会物理删除 S3/本地文件
* 3. 永久删除时会删除关联记录(MaterialAsset, MaterialSku)
* 4. 增加 ID 格式校验
*/
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 })
// 使用统一的权限检查函数(与单条删除保持一致)
if (!session || !isSystemAdmin((session.user as any)?.role)) {
return NextResponse.json({ error: '无权限,需要系统管理员权限' }, { status: 403 })
}
const { ids, permanent } = await request.json()
// 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 })
}
// 校验每个 ID 的格式(防止注入)
const invalidIds = ids.filter(id => typeof id !== 'string' || !UUID_REGEX.test(id))
if (invalidIds.length > 0) {
return NextResponse.json({
error: `存在无效的素材ID格式: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}`
}, { status: 400 })
}
if (permanent) {
// 永久删除:从数据库中彻底删除
await prisma.material.deleteMany({
where: {
id: { in: ids }
// ========== 永久删除:物理删除文件 + 删除数据库记录 ==========
console.log(`[BatchDelete] 开始永久删除 ${ids.length} 条素材...`)
// 1. 先查询所有素材(获取文件路径和素材类型)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
// 2. 收集所有文件并删除
let totalFilesDeleted = 0
let totalFilesFailed = 0
const allErrors: Array<{ materialId: string; fileUrl: string; error: string }> = []
for (const material of materials) {
const materialType = (material as any).materialType || ''
// 收集该素材的所有文件
const fileInfo = await collectMaterialFiles(material.id, material)
const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]
console.log(`[BatchDelete] Material ${material.id} (${materialType}):`, {
fileUrls: fileInfo.fileUrls.length,
directories: fileInfo.directories.length,
relatedRecords: fileInfo.relatedRecords,
})
// 删除文件
if (filesToDelete.length > 0) {
const result = await deleteFiles(filesToDelete)
totalFilesDeleted += result.deletedCount
totalFilesFailed += result.failedCount
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => ({
materialId: material.id,
fileUrl: e.fileUrl,
error: e.error,
})))
}
}
// 删除关联记录(MaterialAsset, MaterialSku)
if (materialType) {
await deleteMaterialRelatedRecords(material.id, materialType)
}
}
// 3. 删除数据库记录
const deleteResult = await prisma.material.deleteMany({
where: { id: { in: ids } }
})
console.log(`[BatchDelete] 永久删除完成:`, {
requestedCount: ids.length,
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
errorCount: allErrors.length,
})
// 如果有文件删除失败,记录警告但不影响整体结果
if (allErrors.length > 0) {
console.warn(`[BatchDelete] 部分文件删除失败:`, allErrors.slice(0, 5))
}
return NextResponse.json({
success: true,
message: `批量永久删除成功`,
details: {
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
}
})
} else {
// 软删除:设置deletedAt字段,同时将任务号作废
// 需要逐条处理,因为每条记录的任务号后缀不同
// ========== 软删除:仅标记 deletedAt ==========
// 查询素材(需要处理任务号)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
select: { id: true, taskNo: true }
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
const now = Date.now()
await Promise.all(materials.map((m, index) =>
@ -39,19 +141,24 @@ export async function POST(request: NextRequest) {
where: { id: m.id },
data: {
deletedAt: new Date(),
// 如果有任务号,加上删除时间戳后缀使其作废
// 如果有任务号,加上删除时间戳后缀使其作废(防止重复)
...(m.taskNo ? { taskNo: `${m.taskNo}_DELETED_${now}_${index}` } : {})
}
})
))
}
return NextResponse.json({
success: true,
message: permanent ? '批量永久删除成功' : '批量删除成功'
})
console.log(`[BatchDelete] 软删除完成: ${materials.length} 条素材`)
return NextResponse.json({
success: true,
message: '批量删除成功(已移入回收站)',
details: {
deletedRecords: materials.length,
}
})
}
} catch (error) {
console.error('Batch delete materials error:', error)
console.error('[BatchDelete] Error:', error)
return NextResponse.json({ error: '批量删除失败' }, { status: 500 })
}
}

View File

@ -355,8 +355,14 @@ export async function GET(request: NextRequest) {
// 超级管理员不受限制
const userId = (session.user as any).id;
const sessionIsSuperAdmin = (session.user as any).isSuperAdmin;
// 优先使用 session 中的 isSuperAdmin,如果为 true 则直接跳过权限过滤
// 如果 session 中没有,则从数据库查询
const { isSuperAdmin } = await import('@/lib/department-permission');
if (!(await isSuperAdmin(userId))) {
const isUserSuperAdmin = sessionIsSuperAdmin === true || (userId && await isSuperAdmin(userId));
if (!isUserSuperAdmin) {
const viewableTypes = await getUserViewableMaterialTypes(
userDepartment,
userRole,
@ -424,10 +430,10 @@ export async function GET(request: NextRequest) {
const totalPages = Math.ceil(totalCount / pageSize);
// 排序
let orderBy: any = { createdAt: sortOrder };
// 排序 - 使用 id 作为次要排序键,确保分页排序稳定(避免重复数据)
let orderBy: any = [{ createdAt: sortOrder }, { id: 'asc' }];
if (sortBy === 'downloadCount') {
orderBy = { downloadCount: sortOrder };
orderBy = [{ downloadCount: sortOrder }, { id: 'asc' }];
}
// 查询素材列表

View File

@ -429,32 +429,21 @@ export async function POST(request: NextRequest) {
}
}
// 如果是复用服务器合并文件,用后清理以节省空间
// 如果是复用服务器合并文件,用后清理整个临时目录
if (sourcePath) {
try {
// 使用统一的文件删除工具
const { deleteLocalFile } = await import('@/lib/file-cleanup');
const result = await deleteLocalFile(sourcePath);
if (result.success) {
console.log('[Upload] 已清理临时合并文件:', sourcePath);
} else {
console.warn('[Upload] 清理临时合并文件失败:', sourcePath, result.error);
}
// 同时清理整个临时目录(如果为空)
const tempDir = path.dirname(sourcePath);
try {
const files = await fs.readdir(tempDir);
if (files.length === 0) {
const { deleteLocalFile } = await import('@/lib/file-cleanup');
await deleteLocalFile(tempDir);
console.log('[Upload] 已清理空临时目录:', tempDir);
}
} catch (dirError) {
// 忽略目录清理错误
// 直接删除整个临时目录(包括合并文件、meta.json等所有内容)
const { cleanupUploadSession } = await import('@/lib/temp-cleanup');
const uploadId = path.basename(tempDir);
const success = await cleanupUploadSession(uploadId);
if (success) {
console.log('[Upload] 已清理临时目录:', tempDir);
} else {
console.warn('[Upload] 清理临时目录失败:', tempDir);
}
} catch (cleanupError) {
console.warn('[Upload] 清理临时合并文件失败(忽略):', cleanupError);
console.warn('[Upload] 清理临时目录失败(忽略):', cleanupError);
}
}
}

View File

@ -164,10 +164,15 @@ export async function GET(request: NextRequest) {
}
// 创建超时控制器(兼容Node.js 18)
// 优化:根据是否有Range请求设置不同的超时时间
// Range请求(视频播放)需要更长超时,完整请求可以更快超时
// 根据域名类型和请求类型设置不同的超时时间:
// - 公网S3域名(通过FRP转发):需要更长超时(60-120秒)
// - 内网MinIO:可以使用较短超时(10-30秒)
// - Range请求(视频播放):需要更长超时
const hasRangeRequest = !!request.headers.get('range')
const timeout = hasRangeRequest ? 30000 : 10000 // Range请求30秒,完整请求10秒
const isPublicS3 = hostname === 's3.yydd.ai' || hostname === 's3.1080p.hk'
const timeout = isPublicS3
? (hasRangeRequest ? 120000 : 60000) // 公网S3: Range 120秒,完整 60秒
: (hasRangeRequest ? 30000 : 10000) // 内网: Range 30秒,完整 10秒
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const q = searchParams.get('q')
if (!q || q.trim().length < 1) {
return NextResponse.json({ suggestions: [] })
}
const keyword = q.trim()
try {
// 并行查询三个字段的前缀匹配和模糊匹配
// 为了性能,我们只取 DISTINCT 的值,并限制数量
// 使用 Promise.all 并行查询
const [spuResults, authorResults, sceneResults] = await Promise.all([
// SPU 型号建议
prisma.material.findMany({
where: {
productModel: { contains: keyword, mode: 'insensitive' },
deletedAt: null
},
select: { productModel: true },
distinct: ['productModel'],
take: 5
}),
// 作者建议
prisma.material.findMany({
where: {
authorName: { contains: keyword, mode: 'insensitive' },
deletedAt: null
},
select: { authorName: true },
distinct: ['authorName'],
take: 3
}),
// 场景建议
prisma.material.findMany({
where: {
sceneName: { contains: keyword, mode: 'insensitive' },
deletedAt: null
},
select: { sceneName: true },
distinct: ['sceneName'],
take: 3
})
])
// 合并结果并去重
const suggestionsSet = new Set<string>()
const suggestions: Array<{ type: 'spu' | 'author' | 'scene', value: string }> = []
const addSuggestion = (type: 'spu' | 'author' | 'scene', value: string) => {
if (value && !suggestionsSet.has(value) && value.toLowerCase().includes(keyword.toLowerCase())) {
suggestionsSet.add(value)
suggestions.push({ type, value })
}
}
spuResults.forEach(i => addSuggestion('spu', i.productModel))
authorResults.forEach(i => addSuggestion('author', i.authorName))
sceneResults.forEach(i => addSuggestion('scene', i.sceneName))
return NextResponse.json({ suggestions: suggestions.slice(0, 10) })
} catch (error) {
console.error('Search suggestions error:', error)
return NextResponse.json({ suggestions: [] }, { status: 500 })
}
}

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useState, useRef, useCallback, useDeferredValue } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from 'next/navigation'
import UploadModal from '@/components/UploadModal'
@ -191,15 +191,59 @@ export default function Home() {
const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色
const [favoriteNotification, setFavoriteNotification] = useState<string | null>(null) // 收藏操作提示
const { toast, showToast } = useToast() // 统一的Toast提示
const searchDebounceRef = useRef<NodeJS.Timeout | null>(null) // 搜索防抖定时器
// 实时搜索:使用 useDeferredValue 实现非阻塞搜索
const deferredSearchKeyword = useDeferredValue(searchKeyword)
const isSearchPending = searchKeyword !== deferredSearchKeyword
const [hasCheckedIntranet, setHasCheckedIntranet] = useState(false) // 是否已检查过内网状态
// 检测内网用户访问公网域名,自动跳转到内网地址
// 同时检测:如果在内网地址但无法访问,自动回退到公网域名
useEffect(() => {
if (hasCheckedIntranet || typeof window === 'undefined') return
const checkIntranet = async () => {
try {
// 判断当前是否在内网地址上
const isOnIntranetAddress = window.location.hostname === '192.168.58.20' ||
window.location.hostname.match(/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./)
// 如果当前在内网地址上,检查是否能正常访问
if (isOnIntranetAddress) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 3000) // 3秒超时
// 尝试访问当前服务器的 API
const testResponse = await fetch('/api/network-status', {
method: 'GET',
cache: 'no-store',
signal: controller.signal
})
clearTimeout(timeoutId)
if (!testResponse.ok) {
throw new Error('API 返回非 200 状态')
}
// 能正常访问,继续正常流程
console.debug('[网络检测] 当前在内网地址,且能正常访问')
} catch (error) {
// 无法访问内网地址,说明用户可能在公网环境但使用了内网地址
console.warn('[网络检测] 当前在内网地址但无法访问,可能用户已切换到公网环境,自动回退到公网域名')
// 重定向到公网域名
const publicDomain = '3d.yydd.ai' // 公网域名
const currentPath = window.location.pathname + window.location.search
window.location.replace(`https://${publicDomain}${currentPath}`)
return
}
// 如果在内网地址且能正常访问,直接返回,不需要进一步检测
setHasCheckedIntranet(true)
return
}
// 判断是否为公网域名
const isPublicDomain = window.location.hostname !== '192.168.58.20' &&
window.location.hostname !== 'localhost' &&
@ -365,27 +409,9 @@ export default function Home() {
return () => clearTimeout(timer)
}, [hasCheckedIntranet])
// 搜索防抖处理函数
// 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制
const handleSearchChange = useCallback((value: string) => {
// 立即更新显示值(不触发搜索,只更新输入框显示)
setSearchKeyword(value)
// 清除之前的定时器
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current)
}
// 设置新的定时器,500ms后触发搜索
searchDebounceRef.current = setTimeout(() => {
console.log('[搜索防抖] 触发搜索:', value)
setCurrentPage(1) // 重置到第一页
setDisplayedCount(24) // 重置显示数量
setHasMore(true) // 重置hasMore状态
// 使用ref访问最新的fetchMaterials,避免闭包问题
if (fetchMaterialsRef.current) {
fetchMaterialsRef.current(1, false)
}
}, 500)
}, [])
// 移除页面卸载时保存状态的逻辑(避免滚动问题)
@ -669,6 +695,66 @@ export default function Home() {
return () => clearTimeout(timer)
}, [status, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
// 实时搜索:当 deferredSearchKeyword 变化时触发搜索
const prevSearchKeywordRef = useRef<string | null>(null)
useEffect(() => {
if (status !== 'authenticated') return
// 首次渲染时,记录初始值但不触发搜索(由筛选 useEffect 处理)
if (prevSearchKeywordRef.current === null) {
prevSearchKeywordRef.current = deferredSearchKeyword
return
}
// 只有搜索词真的变化了才触发搜索
if (deferredSearchKeyword === prevSearchKeywordRef.current) {
return
}
prevSearchKeywordRef.current = deferredSearchKeyword
// 重置分页状态
setCurrentPage(1)
setDisplayedCount(24)
setHasMore(true)
// 直接调用搜索请求(不通过 fetchMaterialsRef,避免闭包问题)
// 这里不能直接调用 fetchMaterials,因为会有循环依赖
// 解决方案:直接在这里构建并发送请求
const performSearch = async () => {
try {
// 构建查询参数
const params = new URLSearchParams()
params.append('page', '1')
params.append('pageSize', String(pageSize))
// 使用当前 useEffect 中的 deferredSearchKeyword
if (deferredSearchKeyword) params.append('search', deferredSearchKeyword)
if (selectedCategory) params.append('category', selectedCategory)
if (selectedScene) params.append('scene', selectedScene)
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
if (selectedPlatform) params.append('platform', selectedPlatform)
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
if (selectedMonth) params.append('month', selectedMonth)
params.append('sortBy', sortBy)
params.append('sortOrder', sortOrder)
const url = `/api/materials?${params.toString()}`
const response = await fetch(url)
if (!response.ok) throw new Error('搜索请求失败')
const data = await response.json()
setMaterials(data.materials || [])
setHasMore((data.materials || []).length >= pageSize)
setCurrentPage(1)
setDisplayedCount(24)
} catch (error) {
console.error('搜索请求失败:', error)
}
}
performSearch()
}, [status, deferredSearchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize])
// 获取主题颜色
const fetchThemeColor = async () => {
try {
@ -693,8 +779,10 @@ export default function Home() {
const fetchMaterials = useCallback(
async (page = 1, append = false) => {
try {
const showFullLoading = !append && page === 1 && materials.length === 0
if (showFullLoading) {
// 修复:只在首次加载(无搜索词且无结果)时显示全屏加载
// 搜索时只使用 isTransitionLoading,避免整个页面被替换导致 SearchBar 丢失焦点
const isInitialLoad = !append && page === 1 && materials.length === 0 && !deferredSearchKeyword
if (isInitialLoad) {
setLoading(true)
} else if (!append) {
setIsTransitionLoading(true)
@ -702,7 +790,7 @@ export default function Home() {
if (!append) setHasMore(true)
const params = new URLSearchParams()
if (searchKeyword) params.append('search', searchKeyword)
if (deferredSearchKeyword) params.append('search', deferredSearchKeyword)
if (selectedCategory) params.append('category', selectedCategory)
if (selectedScene) params.append('scene', selectedScene)
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
@ -781,7 +869,7 @@ export default function Home() {
}
return 0
},
[materials.length, searchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize]
[materials.length, deferredSearchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize]
)
// 更新fetchMaterialsRef
@ -1321,11 +1409,13 @@ export default function Home() {
if (xhr.status === 200) {
try {
const blob = new Blob([xhr.response], { type: 'application/zip' })
// 从响应头获取 Content-Type(视频、音频等不同类型)
const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream'
const blob = new Blob([xhr.response], { type: contentType })
// 从响应头获取文件名
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material.zip'
let filename = 'material' // 默认文件名,扩展名从服务器获取
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
@ -1575,11 +1665,12 @@ export default function Home() {
</button>
</div>
{/* Search Bar - 使用新组件 */}
{/* Search Bar - 实时搜索 */}
<SearchBar
value={searchKeyword}
onChange={handleSearchChange}
placeholder="搜索SPU、店铺、场景类别、作者..."
isSearching={isSearchPending}
/>
{/* 筛选面板组件 */}
@ -1718,6 +1809,7 @@ export default function Home() {
downloadingId={downloadingId}
downloadProgress={downloadProgress}
themeColor={themeColor}
searchKeyword={searchKeyword}
hasFilters={!!(searchKeyword || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform)}
onAudioPlay={handleAudioPlay}
onDownload={handleDownload}

View File

@ -42,6 +42,7 @@ import { buildFormData, uploadSingleFile, submitFormData } from '@/components/up
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import { useCategoryData } from '@/hooks/useCategoryData'
import { parseApiError } from '@/lib/error-handler'
import { parseSPU } from '@/lib/spu-parser'
import UploadForm from '@/components/upload/UploadForm'
import {
BatchMusicList,
@ -145,6 +146,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const displayImageDropZoneRef = useRef<HTMLDivElement>(null) // 详情模板展示图片拖拽区域
const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const xhrRef = useRef<XMLHttpRequest | null>(null)
const singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志
const singleUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set()) // 单文件上传的所有活跃XHR
const batchFileInputRef = useRef<HTMLInputElement>(null) // 批量文件选择input
// 加载当前用户可见的素材类型
@ -845,6 +848,16 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
batchUploadXhrRefs.current.delete(item.id)
}
},
// 取消检查回调:让 chunkUpload 内部能够感知取消状态
isCancelled: () => batchUploadCancelRef.current.cancelled,
// 活跃 XHR 集合回调:跟踪所有并发的 XHR 以便批量取消
onActiveXhrChange: (xhrs) => {
xhrs.forEach(xhr => {
if (!Array.from(batchUploadXhrRefs.current.values()).includes(xhr)) {
batchUploadXhrRefs.current.set(`${item.id}-chunk-${Date.now()}`, xhr)
}
})
},
skipComplete: true,
maxRetries: Infinity,
})
@ -1408,12 +1421,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
return
}
// 重置重试计数
// 重置重试计数和取消状态
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
setIsProcessing(false)
singleUploadCancelRef.current.cancelled = false
singleUploadXhrsRef.current.clear()
try {
// 大文件走分片,小文件继续直传
@ -1438,8 +1453,19 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
setUploadProgress(100)
}
},
// 取消检查回调
isCancelled: () => singleUploadCancelRef.current.cancelled,
// 活跃XHR集合回调
onActiveXhrChange: (xhrs) => {
xhrs.forEach(xhr => singleUploadXhrsRef.current.add(xhr))
},
})
// 如果已取消,不继续后续操作
if (singleUploadCancelRef.current.cancelled) {
return
}
const formData = buildFormData({
previewImages,
displayImage,
@ -1474,6 +1500,15 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onClose()
},
materialType,
// 暴露XHR以便取消
onRequest: (xhr) => {
if (xhr) {
xhrRef.current = xhr
singleUploadXhrsRef.current.add(xhr)
} else {
xhrRef.current = null
}
},
})
} catch (error) {
console.error('Upload error:', error)
@ -1496,13 +1531,48 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}
const handleCancelUpload = () => {
const handleCancelUpload = async () => {
if (isBatchMode) {
// 批量上传模式:调用批量取消逻辑
handleCancelBatchUpload()
} else if (xhrRef.current && uploading) {
// 单文件上传模式:中止XHR请求
xhrRef.current.abort()
} else if (uploading) {
// 单文件上传模式:设置取消标志并中止所有XHR请求
singleUploadCancelRef.current.cancelled = true
// 中止所有活跃的XHR(包括分片上传的)
singleUploadXhrsRef.current.forEach(xhr => {
try {
xhr.abort()
} catch (e) {
// 忽略abort错误
}
})
singleUploadXhrsRef.current.clear()
// 中止当前的XHR(如果有)
if (xhrRef.current) {
try {
xhrRef.current.abort()
} catch (e) {
// 忽略abort错误
}
xhrRef.current = null
}
// 清理上传状态
if (zipFile && zipFile.size >= CHUNK_THRESHOLD) {
try {
const { deleteUploadState, generateFileId } = await import('@/lib/chunk-upload')
const fileId = generateFileId(zipFile)
await deleteUploadState(fileId)
console.log('[UploadModal] 取消上传,已清理上传状态:', fileId)
} catch (cleanupError) {
console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError)
}
}
setUploading(false)
setError('上传已取消')
} else {
onClose()
}
@ -1678,10 +1748,54 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchFiles]
let processedValue = value
if (materialType === '材质') {
newFiles[index].productModel = value
newFiles[index].productModelSource = 'manual' // 用户手动输入
} else {
newFiles[index].productModel = value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
processedValue = value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
newFiles[index].productModel = processedValue
newFiles[index].productModelSource = 'manual' // 用户手动输入,最高优先级
// SPU 自动识别店铺和品类
const result = parseSPU(processedValue)
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists) {
newFiles[index].categoryName = result.shopName
}
}
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
newFiles[index].productCategoryName = result.categoryName
}
}
// 批量同步:将SPU填充到其他非手动输入的项目
// 优先级:manual(不覆盖) > sync(可覆盖) > filename(可覆盖) > 空(可覆盖)
if (processedValue) {
newFiles.forEach((file, i) => {
if (i !== index && file.productModelSource !== 'manual') {
// 覆盖 filename、sync 来源或空值的项目
file.productModel = processedValue
file.productModelSource = 'sync' // 标记为同步填充
// 同时为同步填充的项目自动识别店铺和品类
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists) {
file.categoryName = result.shopName
}
}
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
file.productCategoryName = result.categoryName
}
}
}
})
}
}
setBatchFiles(newFiles)
}}
@ -1897,6 +2011,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
onUpdateProductModel={(index, value) => {
const newFiles = [...batchVideoFiles]
newFiles[index].productModel = value
// SPU 自动识别品类
const result = parseSPU(value)
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
newFiles[index].productCategoryName = result.categoryName
}
}
setBatchVideoFiles(newFiles)
}}
onUpdateProductCategory={(index, value) => {

View File

@ -1,15 +1,22 @@
/**
* 素材管理组件
* 提取自 app/admin/page.tsx 的素材管理功能
*
* 搜索逻辑:复用前台 SearchBar 组件,支持 IME 中文输入
* 使用 useDeferredValue 替代手动防抖,避免闭包问题
*/
'use client'
import { useRef, useEffect, useCallback } from 'react'
import { useRef, useEffect, useCallback, useDeferredValue, useState } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import type { Material } from '@/hooks/useAdminState'
import { parseFetchError, handleError } from '@/lib/error-handler'
import SearchBar from '@/components/home/SearchBar'
// 搜索分页配置
const SEARCH_PAGE_SIZE = 50
interface MaterialManagementProps {
materials: Material[]
@ -21,12 +28,15 @@ interface MaterialManagementProps {
materialSearchTerm: string
setMaterialSearchTerm: React.Dispatch<React.SetStateAction<string>>
displayedMaterialsCount: number
setDisplayedMaterialsCount: React.Dispatch<React.SetStateAction<number>>
hasMoreMaterials: boolean
setHasMoreMaterials: React.Dispatch<React.SetStateAction<boolean>>
isLoadingMoreMaterials: boolean
showToast: (message: string, type?: 'success' | 'error') => void
searchMaterials: (searchTerm: string) => Promise<void>
loadMoreMaterials: () => Promise<void>
fetchData: () => Promise<void>
materialsPageSize: number
totalMaterialsCount?: number // 素材总数(用于显示)
}
export default function MaterialManagement({
@ -39,34 +49,144 @@ export default function MaterialManagement({
materialSearchTerm,
setMaterialSearchTerm,
displayedMaterialsCount,
setDisplayedMaterialsCount,
hasMoreMaterials,
setHasMoreMaterials,
isLoadingMoreMaterials,
showToast,
searchMaterials,
loadMoreMaterials,
fetchData,
materialsPageSize,
totalMaterialsCount,
}: MaterialManagementProps) {
const router = useRouter()
const loadMoreRef = useRef<HTMLDivElement>(null)
const materialSearchDebounceRef = useRef<NodeJS.Timeout | null>(null)
// 搜索分页状态(组件内部管理)
const [searchPage, setSearchPage] = useState(1)
const [hasMoreSearchResults, setHasMoreSearchResults] = useState(false)
const [isLoadingMoreSearch, setIsLoadingMoreSearch] = useState(false)
const [totalSearchCount, setTotalSearchCount] = useState(0)
// 批量操作加载状态
const [isBatchProcessing, setIsBatchProcessing] = useState(false)
// 使用 useDeferredValue 替代手动防抖,与前台保持一致
const deferredSearchKeyword = useDeferredValue(materialSearchTerm)
const isSearching = materialSearchTerm !== deferredSearchKeyword
// 判断是否处于搜索模式
const isInSearchMode = deferredSearchKeyword.trim().length > 0
// 素材搜索处理函数(局部刷新,使用防抖)
const handleMaterialSearchChange = useCallback((value: string) => {
// 搜索处理函数 - 直接更新状态,搜索由 useDeferredValue 控制
const handleSearchChange = useCallback((value: string) => {
setMaterialSearchTerm(value)
if (materialSearchDebounceRef.current) {
clearTimeout(materialSearchDebounceRef.current)
}
if (!value.trim()) {
searchMaterials('')
}, [setMaterialSearchTerm])
// 实时搜索:当 deferredSearchKeyword 变化时触发搜索
// 注意:showRecycleBin 的变化由父组件 fetchData 处理,这里只处理搜索词变化
// 搜索模式使用分页加载,每页 50 条
const prevSearchKeywordRef = useRef<string | null>(null)
useEffect(() => {
// 首次渲染时,记录初始值但不触发搜索(由 fetchData 处理)
if (prevSearchKeywordRef.current === null) {
prevSearchKeywordRef.current = deferredSearchKeyword
return
}
materialSearchDebounceRef.current = setTimeout(() => {
searchMaterials(value)
}, 500)
}, [searchMaterials, setMaterialSearchTerm])
// 只有搜索词真的变化了才触发搜索
if (deferredSearchKeyword === prevSearchKeywordRef.current) {
return
}
prevSearchKeywordRef.current = deferredSearchKeyword
// 重置搜索分页状态
setSearchPage(1)
setHasMoreSearchResults(false)
setTotalSearchCount(0)
// 直接发送搜索请求(避免闭包问题)
const performSearch = async () => {
try {
const hasSearch = deferredSearchKeyword.trim().length > 0
const params = new URLSearchParams()
params.append('page', '1')
// 搜索时使用分页(50条/页),非搜索时使用默认分页
params.append('pageSize', String(hasSearch ? SEARCH_PAGE_SIZE : materialsPageSize))
if (showRecycleBin) params.append('deleted', 'true')
if (hasSearch) params.append('search', deferredSearchKeyword.trim())
const url = `/api/materials?${params.toString()}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
const materialsData = data.materials || data
const total = data.total || materialsData.length
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
if (hasSearch) {
// 搜索模式:检查是否还有更多结果
setTotalSearchCount(total)
setHasMoreSearchResults(materialsData.length >= SEARCH_PAGE_SIZE)
setHasMoreMaterials(false) // 搜索模式下禁用普通分页
} else {
// 非搜索模式:恢复普通分页
setHasMoreMaterials(materialsData.length >= materialsPageSize)
}
}
} catch (error) {
console.error('搜索素材失败:', error)
}
}
performSearch()
// 注意:不要把 showRecycleBin 放在这个 useEffect 的依赖里,回收站切换由父组件处理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deferredSearchKeyword, materialsPageSize, setMaterials, setDisplayedMaterialsCount, setHasMoreMaterials])
// 加载更多搜索结果
const loadMoreSearchResults = useCallback(async () => {
if (isLoadingMoreSearch || !hasMoreSearchResults || !isInSearchMode) return
setIsLoadingMoreSearch(true)
try {
const nextPage = searchPage + 1
const params = new URLSearchParams()
params.append('page', String(nextPage))
params.append('pageSize', String(SEARCH_PAGE_SIZE))
if (showRecycleBin) params.append('deleted', 'true')
params.append('search', deferredSearchKeyword.trim())
const url = `/api/materials?${params.toString()}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
const newMaterials = data.materials || data
if (newMaterials.length > 0) {
// 去重:过滤掉已经存在的素材
setMaterials(prev => {
const existingIds = new Set(prev.map(m => m.id))
const uniqueNewMaterials = newMaterials.filter((m: any) => !existingIds.has(m.id))
return [...prev, ...uniqueNewMaterials]
})
setDisplayedMaterialsCount(prev => prev + newMaterials.length)
setSearchPage(nextPage)
setHasMoreSearchResults(newMaterials.length >= SEARCH_PAGE_SIZE)
} else {
setHasMoreSearchResults(false)
}
}
} catch (error) {
console.error('加载更多搜索结果失败:', error)
} finally {
setIsLoadingMoreSearch(false)
}
}, [isLoadingMoreSearch, hasMoreSearchResults, isInSearchMode, searchPage, showRecycleBin, deferredSearchKeyword, setMaterials, setDisplayedMaterialsCount])
// 删除单个素材
const handleDeleteMaterial = async (id: string) => {
@ -105,6 +225,7 @@ export default function MaterialManagement({
if (!confirm(confirmMessage)) return
setIsBatchProcessing(true)
try {
const response = await fetch('/api/admin/materials/batch-delete', {
method: 'POST',
@ -116,9 +237,15 @@ export default function MaterialManagement({
})
if (response.ok) {
const result = await response.json()
setMaterials(prev => prev.filter(m => !selectedIds.includes(m.id)))
setSelectedMaterials(new Set())
showToast(showRecycleBin ? '批量永久删除完成' : '批量删除完成')
// 显示详细信息(如果是永久删除,显示文件清理信息)
if (showRecycleBin && result.details) {
showToast(`批量永久删除完成:删除 ${result.details.deletedRecords} 条记录,清理 ${result.details.filesDeleted} 个文件`)
} else {
showToast(showRecycleBin ? '批量永久删除完成' : '批量删除完成')
}
} else {
const error = await parseFetchError(response)
showToast(error.message, 'error')
@ -127,6 +254,8 @@ export default function MaterialManagement({
console.error('Batch delete materials error:', error)
const { message } = handleError(error)
showToast(message, 'error')
} finally {
setIsBatchProcessing(false)
}
}
@ -143,12 +272,22 @@ export default function MaterialManagement({
})
}
// 全选/取消全选
// 全选/取消全选 - 只选择当前显示的素材
const toggleSelectAll = () => {
if (materials.length > 0 && materials.every(m => selectedMaterials.has(m.id))) {
// 获取当前显示的素材列表
const displayedMaterials = materials.slice(0, displayedMaterialsCount)
if (displayedMaterials.length === 0) return
// 检查当前显示的素材是否全部被选中
const allDisplayedSelected = displayedMaterials.every(m => selectedMaterials.has(m.id))
if (allDisplayedSelected) {
// 取消全选:清空所有选中
setSelectedMaterials(new Set())
} else {
setSelectedMaterials(new Set(materials.map(m => m.id)))
// 全选:只选择当前显示的素材
setSelectedMaterials(new Set(displayedMaterials.map(m => m.id)))
}
}
@ -226,12 +365,22 @@ export default function MaterialManagement({
}
}
// 监听滚动,实现懒加载
// 监听滚动,实现懒加载(搜索模式和非搜索模式分别处理)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreMaterials && !isLoadingMoreMaterials) {
loadMoreMaterials()
if (!entries[0].isIntersecting) return
if (isInSearchMode) {
// 搜索模式:加载更多搜索结果
if (hasMoreSearchResults && !isLoadingMoreSearch) {
loadMoreSearchResults()
}
} else {
// 非搜索模式:使用父组件的加载更多
if (hasMoreMaterials && !isLoadingMoreMaterials) {
loadMoreMaterials()
}
}
},
{ threshold: 0.1 }
@ -242,114 +391,199 @@ export default function MaterialManagement({
}
return () => observer.disconnect()
}, [hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials])
}, [isInSearchMode, hasMoreSearchResults, isLoadingMoreSearch, loadMoreSearchResults, hasMoreMaterials, isLoadingMoreMaterials, loadMoreMaterials])
return (
<div className="card-apple overflow-hidden">
{/* 素材搜索框和批量操作 */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 space-y-4">
{/* 搜索框 */}
<div className="relative">
<input
type="text"
placeholder="搜索素材:SPU型号、店铺、场景、作者..."
value={materialSearchTerm}
onChange={(e) => handleMaterialSearchChange(e.target.value)}
className="w-full pl-10 pr-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"
/>
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{materialSearchTerm && (
{/* 顶部工具栏:状态切换 + 操作 + 搜索框 */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm sticky top-0 z-20">
{/* 左侧:Tab 切换 + 分隔线 + 常用操作 */}
<div className="flex items-center gap-4 flex-wrap">
{/* 状态切换 (Segmented Control) */}
<div className="bg-gray-100 dark:bg-gray-900/50 p-1 rounded-lg flex items-center shadow-inner border border-gray-200 dark:border-gray-700/50">
<button
onClick={() => handleMaterialSearchChange('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
onClick={() => setShowRecycleBin(false)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${
!showRecycleBin
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<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>
<button
onClick={() => setShowRecycleBin(true)}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
showRecycleBin
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
回收站
{showRecycleBin && totalMaterialsCount !== undefined && totalMaterialsCount > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 text-[10px] font-bold rounded-full">
{totalMaterialsCount}
</span>
)}
</button>
</div>
{/* 回收站模式提示 */}
{showRecycleBin && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-xs text-red-600 dark:text-red-400">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<span className="font-medium">回收站模式</span>
<span className="hidden sm:inline">- 显示已删除素材,删除操作为永久删除</span>
{totalMaterialsCount !== undefined && (
<span className="font-bold">(共 {totalMaterialsCount} 条)</span>
)}
</div>
)}
</div>
{/* 批量操作工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 hidden sm:block"></div>
{/* 批量操作按钮组 */}
<div className="flex items-center gap-2">
<button
onClick={toggleSelectAll}
className="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
disabled={isBatchProcessing || materials.length === 0}
className={`text-xs font-bold px-3 py-1.5 rounded-lg transition-colors border ${
materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id))
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${isBatchProcessing || materials.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{materials.length > 0 && materials.every(m => selectedMaterials.has(m.id))
? '取消全选'
: '全选当前结果'}
{materials.length > 0 && materials.slice(0, displayedMaterialsCount).every(m => selectedMaterials.has(m.id))
? '取消'
: '全选'}
</button>
{selectedMaterials.size > 0 && (
<button
onClick={handleBatchDeleteMaterials}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
>
{showRecycleBin ? '批量永久删除' : '批量删除'}
</button>
<>
<div className="hidden sm:flex items-center px-2 text-xs font-medium text-gray-500">
已选 {selectedMaterials.size}
</div>
<button
onClick={handleBatchDeleteMaterials}
disabled={isBatchProcessing}
className={`px-3 py-1.5 text-white text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 shadow-sm active:scale-95 ${
isBatchProcessing
? 'bg-red-400 cursor-not-allowed'
: showRecycleBin
? 'bg-red-600 hover:bg-red-700'
: 'bg-red-500 hover:bg-red-600'
}`}
>
{isBatchProcessing && (
<svg className="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{showRecycleBin ? '彻底删除' : '删除'}
</button>
{showRecycleBin && !isBatchProcessing && (
<button
onClick={async () => {
if (!confirm(`确定要恢复选中的 ${selectedMaterials.size} 个素材吗?`)) return;
setIsBatchProcessing(true);
try {
let successCount = 0;
const ids = Array.from(selectedMaterials);
for (const id of ids) {
try {
const res = await fetch('/api/materials/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (res.ok) successCount++;
} catch (e) {
console.error(`恢复素材 ${id} 失败`, e);
}
}
setMaterials(prev => prev.filter(m => !selectedMaterials.has(m.id)));
setSelectedMaterials(new Set());
showToast(`成功恢复 ${successCount} 个素材`);
fetchData();
} catch (error) {
showToast('批量恢复部分失败', 'error');
} finally {
setIsBatchProcessing(false);
}
}}
className="px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg transition-all shadow-sm active:scale-95"
>
恢复
</button>
)}
</>
)}
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input
type="checkbox"
className="rounded"
checked={showRecycleBin}
onChange={(e) => setShowRecycleBin(e.target.checked)}
/>
查看回收站
</label>
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
共 {materials.length} 个{showRecycleBin ? '(回收站)' : ''}
</span>
</div>
{/* 右侧:搜索框 */}
<div className="w-full md:w-80 lg:w-96 flex-shrink-0">
<SearchBar
value={materialSearchTerm}
onChange={handleSearchChange}
placeholder="搜索素材..."
isSearching={isSearching}
/>
</div>
</div>
{/* 卡片网格布局 */}
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
{materials.slice(0, displayedMaterialsCount).map((material) => {
const isVideo = material.materialType === '主图视频' ||
material.materialType === '安装视频' ||
material.materialType === '种草视频'
const posterUrl = material.videoCoverUrl || material.previewUrl
const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url
const isSelected = selectedMaterials.has(material.id)
return (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow hover:shadow-lg transition-shadow"
>
{/* 选择框 */}
<button
onClick={(e) => {
e.stopPropagation()
onClick={() => {
if (!isBatchProcessing) {
toggleMaterialSelection(material.id)
}}
className={`absolute z-10 top-2 left-2 w-7 h-7 rounded-full border-2 flex items-center justify-center text-xs font-semibold transition-colors ${
selectedMaterials.has(material.id)
? 'bg-indigo-600 border-indigo-600 text-white'
: 'bg-white/90 border-gray-300 text-transparent hover:text-gray-400'
}`}
aria-pressed={selectedMaterials.has(material.id)}
aria-label={selectedMaterials.has(material.id) ? '取消选择素材' : '选择素材'}
>
✓
</button>
}
}}
className={`group relative aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 dark:bg-gray-800 border transition-all duration-300 cursor-pointer ${
isSelected
? 'border-indigo-500 ring-2 ring-indigo-500 dark:ring-indigo-400 shadow-lg scale-[0.98]'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-lg hover:-translate-y-1'
}`}
>
{/* 选择指示器 (更像原生选择) */}
<div className={`absolute z-20 top-2.5 right-2.5 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
isSelected
? 'bg-blue-500 text-white shadow-md scale-100'
: 'bg-black/20 hover:bg-black/40 border border-white/30 backdrop-blur-sm'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* 预览图/视频 */}
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700">
<div className="relative w-full h-full">
{isVideo && videoUrl ? (
<video
src={videoUrl}
poster={posterUrl || undefined}
className="w-full h-full object-cover"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
muted
loop
playsInline
@ -359,37 +593,38 @@ export default function MaterialManagement({
<img
src={material.previewUrl}
alt={material.productModel}
className="w-full h-full object-cover"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
loading="lazy"
/>
)}
{/* 商品型号角标 */}
<div className="absolute top-2 left-10 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
{material.productModel}
{/* 渐变遮罩 (底部信息) */}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-90" />
{/* 顶部左侧信息 (型号) */}
<div className="absolute top-3 left-3 right-10 overflow-hidden">
<span className="inline-block bg-black/40 backdrop-blur-md text-white/90 text-[10px] font-semibold px-2 py-0.5 rounded shadow-sm truncate max-w-full border border-white/10">
{material.productModel}
</span>
</div>
{/* 素材类型角标 */}
{material.materialType && (
<div className={`absolute top-2 right-2 text-white px-2 py-1 rounded text-xs font-medium ${getBadgeColor(material.materialType)}`}>
{material.materialType}
</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.sceneName}
<div className="absolute bottom-0 left-0 right-0 p-3 transform transition-transform duration-300 group-hover:-translate-y-1">
<p className="text-xs font-bold text-white truncate mb-1 text-shadow-sm">
{material.sceneName || '无场景名称'}
</p>
<div className="flex items-center justify-between text-xs text-white/80">
<span className="truncate">{material.authorName}</span>
<span className="ml-2 flex-shrink-0">
<div className="flex items-center justify-between text-[10px] text-gray-300 font-medium">
<span className="truncate max-w-[60%] flex items-center gap-1">
{material.authorName}
</span>
<span className="flex-shrink-0 opacity-80 bg-white/10 px-1.5 py-0.5 rounded">
{format(new Date(material.createdAt), 'MM-dd')}
</span>
</div>
</div>
{/* 操作按钮(悬停显示) */}
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
{/* 操作按钮浮层 */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col items-center justify-center gap-2 p-4">
{showRecycleBin ? (
<>
<button
@ -397,7 +632,7 @@ export default function MaterialManagement({
e.stopPropagation()
handleRestoreMaterial(material.id)
}}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors"
className="w-full py-2 bg-green-500 hover:bg-green-600 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
>
恢复
</button>
@ -406,7 +641,7 @@ export default function MaterialManagement({
e.stopPropagation()
handlePermanentDelete(material.id)
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
>
永久删除
</button>
@ -418,7 +653,7 @@ export default function MaterialManagement({
e.stopPropagation()
router.push(`/edit/${material.id}`)
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
className="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold rounded-lg shadow-lg transform hover:scale-105 transition-all"
>
编辑
</button>
@ -427,9 +662,9 @@ export default function MaterialManagement({
e.stopPropagation()
handleDeleteMaterial(material.id)
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
className="w-full py-2 bg-white/20 hover:bg-red-500/80 border border-white/30 text-white text-xs font-bold rounded-lg backdrop-blur-md shadow-lg transform hover:scale-105 transition-all"
>
删除
移入回收站
</button>
</>
)}
@ -441,10 +676,10 @@ export default function MaterialManagement({
</div>
{/* 加载更多触发器 */}
{(displayedMaterialsCount < materials.length || hasMoreMaterials) && (
{(isInSearchMode ? hasMoreSearchResults : (displayedMaterialsCount < materials.length || hasMoreMaterials)) && (
<div ref={loadMoreRef} className="py-8 text-center">
<div className="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400">
{isLoadingMoreMaterials ? (
{(isInSearchMode ? isLoadingMoreSearch : isLoadingMoreMaterials) ? (
<>
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>

View File

@ -61,6 +61,8 @@ interface MaterialCardProps {
downloadProgress: number
// 主题色
themeColor: string
// 筛选状态(用于空状态提示)
hasFilters?: boolean
// 事件处理
onAudioPlay: (materialId: string, audioUrl: string) => void
onDownload: (id: string) => void

View File

@ -29,6 +29,8 @@ interface MaterialGridProps {
themeColor: string
// 筛选状态(用于空状态提示)
hasFilters: boolean
// 搜索关键词(用于高亮)
searchKeyword?: string
// 事件处理
onAudioPlay: (materialId: string, audioUrl: string) => void
onDownload: (id: string) => void
@ -58,6 +60,7 @@ export default function MaterialGrid({
downloadProgress,
themeColor,
hasFilters,
searchKeyword,
onAudioPlay,
onDownload,
onLike,

View File

@ -1,50 +1,257 @@
/**
* 搜索栏组件
* 提取自 app/page.tsx 的搜索功能
* 搜索栏组件 - 支持中文输入法 & 历史记录 & 自动补全
*
* 核心设计:
* - 使用本地状态显示输入内容(包括 IME 组合中的拼音)
* - 只在 IME 组合完成后或非 IME 输入时触发搜索
* - 支持搜索历史记录(localStorage 存储,Enter 键保存)
* - 支持搜索建议(自动补全)
*/
'use client'
import { memo } from 'react'
import { useRef, useState, useCallback, useEffect, memo } from 'react'
interface SearchBarProps {
value: string
onChange: (value: string) => void
placeholder?: string
isSearching?: boolean
}
function SearchBar({
value,
interface Suggestion {
type: 'spu' | 'author' | 'scene'
value: string
}
function SearchBarInner({
value: externalValue,
onChange,
placeholder = '搜索素材:SPU型号、任务号、SKU、店铺、场景、作者...',
isSearching = false,
}: SearchBarProps) {
// 本地状态用于显示(包括 IME 组合中的拼音)
const [localValue, setLocalValue] = useState(externalValue)
// IME 组合状态
const isComposingRef = useRef(false)
// 历史记录状态
const [history, setHistory] = useState<string[]>([])
// 建议状态
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
// 加载历史记录
useEffect(() => {
try {
const saved = localStorage.getItem('searchHistory')
if (saved) {
setHistory(JSON.parse(saved))
}
} catch (e) {
console.error('Failed to load search history', e)
}
}, [])
// 获取建议
useEffect(() => {
const fetchSuggestions = async () => {
if (!localValue || localValue.trim().length < 1 || isComposingRef.current) {
setSuggestions([])
return
}
try {
const res = await fetch(`/api/search/suggestions?q=${encodeURIComponent(localValue.trim())}`)
if (res.ok) {
const data = await res.json()
setSuggestions(data.suggestions || [])
}
} catch (e) {
console.error('Failed to fetch suggestions', e)
}
}
const timer = setTimeout(fetchSuggestions, 300)
return () => clearTimeout(timer)
}, [localValue])
// 保存历史记录
const saveHistory = useCallback((value: string) => {
const trimmed = value.trim()
if (!trimmed) return
setHistory(prev => {
const newHistory = [trimmed, ...prev.filter(h => h !== trimmed)].slice(0, 10)
localStorage.setItem('searchHistory', JSON.stringify(newHistory))
return newHistory
})
}, [])
// 删除单条历史
const deleteHistory = useCallback((e: React.MouseEvent, item: string) => {
e.stopPropagation()
setHistory(prev => {
const newHistory = prev.filter(h => h !== item)
localStorage.setItem('searchHistory', JSON.stringify(newHistory))
return newHistory
})
}, [])
// 清空历史
const clearHistory = useCallback(() => {
setHistory([])
localStorage.removeItem('searchHistory')
}, [])
// 同步外部值到本地值
useEffect(() => {
if (!isComposingRef.current) {
setLocalValue(externalValue)
}
}, [externalValue])
// IME 组合开始
const handleCompositionStart = useCallback(() => {
isComposingRef.current = true
}, [])
// IME 组合结束
const handleCompositionEnd = useCallback((e: React.CompositionEvent<HTMLInputElement>) => {
isComposingRef.current = false
const finalValue = e.currentTarget.value
setLocalValue(finalValue)
onChange(finalValue)
}, [onChange])
// 处理输入变化
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setLocalValue(newValue)
if (!isComposingRef.current) {
onChange(newValue)
}
// 重置选择索引
setSelectedIndex(-1)
setShowDropdown(true)
}, [onChange])
// 处理按键
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (selectedIndex >= 0 && suggestions.length > 0) {
// 选择建议
const item = suggestions[selectedIndex]
setLocalValue(item.value)
onChange(item.value)
saveHistory(item.value)
} else {
// 普通搜索
saveHistory(localValue)
}
setShowDropdown(false)
inputRef.current?.blur()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const maxIndex = (localValue ? suggestions.length : history.length) - 1
setSelectedIndex(prev => (prev < maxIndex ? prev + 1 : 0))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const maxIndex = (localValue ? suggestions.length : history.length) - 1
setSelectedIndex(prev => (prev > 0 ? prev - 1 : maxIndex))
} else if (e.key === 'Escape') {
setShowDropdown(false)
}
}, [localValue, saveHistory, selectedIndex, suggestions, history.length, onChange])
// 清空搜索
const handleClear = useCallback(() => {
setLocalValue('')
onChange('')
setSuggestions([])
}, [onChange])
// 点击建议或历史记录
const handleItemClick = useCallback((value: string) => {
setLocalValue(value)
onChange(value)
saveHistory(value)
setShowDropdown(false)
}, [onChange, saveHistory])
// 处理点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// 渲染建议类型标签
const renderSuggestionType = (type: string) => {
switch (type) {
case 'spu': return <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded">SPU</span>
case 'author': return <span className="text-xs bg-green-100 text-green-600 px-1.5 py-0.5 rounded">作者</span>
case 'scene': return <span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">场景</span>
default: return null
}
}
// 决定显示什么内容
const showSuggestionsList = localValue && suggestions.length > 0
const showHistoryList = !localValue && history.length > 0
const shouldShowDropdown = showDropdown && (showSuggestionsList || showHistoryList)
return (
<div className="relative mb-6">
<div ref={containerRef} className="relative mb-6 z-20">
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onFocus={() => setShowDropdown(true)}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
className="w-full pl-10 pr-10 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 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 shadow-sm"
/>
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{value && (
{/* 搜索图标 / 加载动画 */}
{isSearching ? (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
) : (
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
{/* 清除按钮 */}
{localValue && (
<button
onClick={() => onChange('')}
onClick={handleClear}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="清除搜索"
type="button"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -56,8 +263,84 @@ function SearchBar({
</svg>
</button>
)}
{/* 下拉列表 */}
{shouldShowDropdown && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden animate-fade-in z-30">
{/* 建议列表 */}
{showSuggestionsList && (
<ul>
{suggestions.map((item, index) => (
<li key={`${item.type}-${item.value}`}>
<button
onClick={() => handleItemClick(item.value)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span className="truncate flex-1">
{item.value}
</span>
{renderSuggestionType(item.type)}
</div>
</button>
</li>
))}
</ul>
)}
{/* 历史记录列表 */}
{showHistoryList && (
<>
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
<span>最近搜索</span>
<button
onClick={clearHistory}
className="hover:text-red-500 transition-colors"
>
清空历史
</button>
</div>
<ul>
{history.map((item, index) => (
<li key={item}>
<button
onClick={() => handleItemClick(item)}
className={`w-full text-left px-4 py-2.5 flex items-center justify-between group transition-colors text-sm text-gray-700 dark:text-gray-200 ${
index === selectedIndex ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2 overflow-hidden">
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="truncate">{item}</span>
</div>
<div
onClick={(e) => deleteHistory(e, item)}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-400 opacity-0 group-hover:opacity-100 transition-all"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
)
}
export default memo(SearchBar)
const SearchBar = memo(SearchBarInner)
export default SearchBar

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
interface AssetFile {
file: File;
@ -60,6 +61,51 @@ export default function AIOriginalUploader({
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
@ -324,11 +370,19 @@ export default function AIOriginalUploader({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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"
disabled={uploading}
>
@ -344,6 +398,11 @@ export default function AIOriginalUploader({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -363,7 +422,10 @@ export default function AIOriginalUploader({
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>

View File

@ -5,6 +5,7 @@ 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'
import { parseSPU } from '@/lib/spu-parser'
interface BatchFileListProps {
batchFiles: BatchFileItem[]
@ -216,11 +217,21 @@ export default function BatchFileList({
)}
{/* 使用店铺和场景类别(如果不是自动通用类型) */}
{requiresCategoryAndScene(materialType) && (
{requiresCategoryAndScene(materialType) && (() => {
// 检查是否是自动识别的店铺
const spuResult = parseSPU(item.productModel)
const isShopAutoFilled = spuResult.shopMatched && spuResult.shopName === item.categoryName
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{isShopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
value={item.categoryName}
@ -256,7 +267,7 @@ export default function BatchFileList({
</select>
</div>
</>
)}
)})()}
{/* 平台分类(可选,仅在后台配置了平台时显示,标准模型不显示) */}
{platforms.length > 0 && materialType !== '标准模型' && (
@ -281,10 +292,20 @@ export default function BatchFileList({
)}
{/* 品类分类(标准模型必填,其他类型可选) */}
{productCategories.length > 0 && (
{productCategories.length > 0 && (() => {
// 检查是否是自动识别的品类
const spuResult = parseSPU(item.productModel)
const isCategoryAutoFilled = spuResult.categoryMatched && spuResult.categoryName === item.productCategoryName
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
{isCategoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
value={item.productCategoryName}
@ -300,7 +321,7 @@ export default function BatchFileList({
))}
</select>
</div>
)}
)})()}
{/* 渲染器(仅3D场景显示) */}
{materialType === '3D场景' && (

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
interface AssetFile {
file: File;
@ -60,6 +61,51 @@ export default function RealPhotoUploader({
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
@ -321,11 +367,19 @@ export default function RealPhotoUploader({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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"
disabled={uploading}
>
@ -341,6 +395,11 @@ export default function RealPhotoUploader({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -360,7 +419,10 @@ export default function RealPhotoUploader({
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser';
interface AssetFile {
file: File;
@ -61,6 +62,51 @@ export default function TaskPageUploader({
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
@ -307,6 +353,11 @@ export default function TaskPageUploader({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -340,7 +391,10 @@ export default function TaskPageUploader({
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className="flex-1 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"
>
<option value="">选择使用店铺</option>
@ -446,11 +500,19 @@ export default function TaskPageUploader({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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"
>
<option value="">不选择品类</option>

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
interface AssetFile {
file: File;
@ -60,6 +61,51 @@ export default function ThreeDOriginalUploader({
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
@ -324,11 +370,19 @@ export default function ThreeDOriginalUploader({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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"
disabled={uploading}
>
@ -344,6 +398,11 @@ export default function ThreeDOriginalUploader({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -363,7 +422,10 @@ export default function ThreeDOriginalUploader({
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>

View File

@ -5,8 +5,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser'
/**
* 分类数据项
@ -98,8 +99,62 @@ export default function UploadForm({
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
const [newSceneName, setNewSceneName] = useState('')
// SPU 自动识别状态
const [spuParseResult, setSpuParseResult] = useState<SPUParseResult | null>(null)
const [shopAutoFilled, setShopAutoFilled] = useState(false)
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false)
const lastParsedSPU = useRef<string>('')
const isAutoGeneralType = shouldAutoFillGeneral(materialType)
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
// 避免重复解析相同的 SPU
if (productModel === lastParsedSPU.current) {
return
}
lastParsedSPU.current = productModel
// 材质类型不需要解析
if (materialType === '材质' || !productModel) {
setSpuParseResult(null)
setShopAutoFilled(false)
setCategoryAutoFilled(false)
return
}
const result = parseSPU(productModel)
setSpuParseResult(result)
// 自动填充店铺(如果识别成功且店铺列表中存在该店铺)
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists && categoryName !== result.shopName) {
onCategoryChange(result.shopName)
setShopAutoFilled(true)
} else if (!shopExists) {
// 店铺不在列表中,清除自动填充标记
setShopAutoFilled(false)
}
} else {
setShopAutoFilled(false)
}
// 自动填充品类(如果识别成功且品类列表中存在该品类)
if (result.categoryMatched && result.categoryName) {
const categoryExists = productCategories.some(c => c.name === result.categoryName)
if (categoryExists && productCategoryName !== result.categoryName) {
onProductCategoryChange(result.categoryName)
setCategoryAutoFilled(true)
} else if (!categoryExists) {
// 品类不在列表中,清除自动填充标记
setCategoryAutoFilled(false)
}
} else {
setCategoryAutoFilled(false)
}
}, [productModel, materialType, categories, productCategories, categoryName, productCategoryName, onCategoryChange, onProductCategoryChange])
// 处理添加新店铺
const handleAddNewCategory = async () => {
@ -172,11 +227,20 @@ export default function UploadForm({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => onProductCategoryChange(e.target.value)}
onChange={(e) => {
onProductCategoryChange(e.target.value)
// 手动修改时清除自动填充标记
setCategoryAutoFilled(false)
}}
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"
>
<option value="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
@ -194,6 +258,11 @@ export default function UploadForm({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -227,7 +296,11 @@ export default function UploadForm({
<select
id="categoryName"
value={categoryName}
onChange={(e) => onCategoryChange(e.target.value)}
onChange={(e) => {
onCategoryChange(e.target.value)
// 手动修改时清除自动填充标记
setShopAutoFilled(false)
}}
disabled={isAutoGeneralType}
className={`flex-1 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 ${isAutoGeneralType ? 'opacity-70 cursor-not-allowed' : ''}`}
>

View File

@ -1,7 +1,8 @@
'use client'
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU } from '@/lib/spu-parser';
interface AssetFile {
file: File;
@ -61,6 +62,51 @@ export default function VideoScreenshotUploader({
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
@ -339,11 +385,19 @@ export default function VideoScreenshotUploader({
<div>
<label htmlFor="productCategoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(e.target.value)}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
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"
disabled={uploading}
>
@ -359,6 +413,11 @@ export default function VideoScreenshotUploader({
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
@ -378,7 +437,10 @@ export default function VideoScreenshotUploader({
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className="flex-1 px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white outline-none"
disabled={uploading}
>

View File

@ -35,6 +35,7 @@ export function createBatchFileItems(
file,
expanded: index === 0, // 默认展开第一个
productModel: extractedSpu,
productModelSource: extractedSpu ? 'filename' : undefined, // 从文件名提取的SPU,优先级最低,可被覆盖
categoryName: shouldAutoFillGeneral(materialType) ? '通用' : categoryName,
sceneName: shouldAutoFillGeneral(materialType) ? '通用' : sceneName,
platformName: platformName,
@ -84,6 +85,7 @@ export function createBatchItemFromSingleFile(
file,
expanded: true,
productModel: extractedSpu,
productModelSource: extractedSpu ? 'filename' : undefined, // 从文件名提取的SPU,优先级最低,可被覆盖
categoryName: shouldAutoFillGeneral(materialType) ? '通用' : categoryName,
sceneName: shouldAutoFillGeneral(materialType) ? '通用' : sceneName,
platformName: platformName,

View File

@ -99,6 +99,8 @@ export async function uploadSingleFile(params: {
renderer: string
onProgress: (progress: number, speed?: number) => void
onStageChange?: (stage: string) => void
isCancelled?: () => boolean // 取消检查回调
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 活跃XHR集合回调
}): Promise<{ existingFilePath?: string; originalFileName?: string } | null> {
const {
zipFile,
@ -114,6 +116,8 @@ export async function uploadSingleFile(params: {
renderer,
onProgress,
onStageChange,
isCancelled,
onActiveXhrChange,
} = params
if (!zipFile) return null
@ -140,6 +144,8 @@ export async function uploadSingleFile(params: {
onProgress(100)
}
},
isCancelled, // 传递取消检查回调
onActiveXhrChange, // 传递活跃XHR集合回调
})
return {
@ -165,6 +171,7 @@ export async function submitFormData(
onError?: (error: string) => void
onSuccess?: () => void
materialType?: string
onRequest?: (xhr: XMLHttpRequest | null) => void // 暴露XHR以便取消
}
): Promise<void> {
const progressBase = opts?.progressBase ?? 0
@ -173,6 +180,9 @@ export async function submitFormData(
return await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
// 暴露 XHR 以便外部可以取消
opts?.onRequest?.(xhr)
xhr.timeout = 0
let startTime = Date.now()
@ -213,6 +223,7 @@ export async function submitFormData(
})
xhr.addEventListener('load', () => {
opts?.onRequest?.(null) // XHR 完成,通知外部
if (xhr.status === 201) {
// 上传接口返回 201 即视为“上传完成”,不再在前端等待后台的后续处理(如视频转码等)
if (opts?.onProgress) {
@ -256,6 +267,7 @@ export async function submitFormData(
})
xhr.addEventListener('error', (e) => {
opts?.onRequest?.(null) // XHR 完成,通知外部
console.error('Upload XHR error:', e)
console.error('XHR status:', xhr.status)
console.error('XHR readyState:', xhr.readyState)
@ -276,6 +288,7 @@ export async function submitFormData(
})
xhr.addEventListener('abort', () => {
opts?.onRequest?.(null) // XHR 取消,通知外部
const msg = '上传已取消'
if (opts?.onError) {
opts.onError(msg)

View File

@ -6,6 +6,7 @@ export interface BatchFileItem {
file: File
expanded: boolean // 是否展开显示配置
productModel: string
productModelSource?: 'filename' | 'manual' | 'sync' // SPU来源:filename=从文件名提取(可覆盖), manual=用户手动输入(不可覆盖), sync=自动同步(可覆盖)
previewImage?: File // 预览图(如果需要)
previewImageUrl?: string // 预览图URL
categoryName: string

View File

@ -0,0 +1,322 @@
# FRP 穿透服务故障诊断与修复报告
**日期**: 2026-01-30
**问题**: 网站频繁无法访问,FRP 穿透服务不稳定
---
## 问题概述
素材库网站通过 FRP 穿透服务对外提供访问,但近期频繁出现无法访问的情况,约 2 天崩溃一次。
## 系统架构
```
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ 生产服务器 │ │ FRP 服务器 │ │ 公网用户 │
│ 192.168.58.20 │────→│ 180.97.221.140 │←────│ │
│ - 素材库 Docker │ │ - frps 监听 7000 │ │ 访问 :2320 │
│ - frpc 客户端 │ │ - 转发端口 2320 │ └─────────────┘
└─────────────────────┘ └──────────────────────┘
```
## 诊断过程
### 问题1:FRP 配置冲突
**现象**: 服务器日志出现大量 `timeout trying to get work connection` 错误
**排查**:
```bash
cat /etc/frp/frpc.toml
```
**发现**: 配置文件中同时启用了互斥的选项:
```toml
[transport]
tcpMux = true # TCP 多路复用(单连接)
poolCount = 50 # 连接池(50个连接)
```
**原因**:
- `tcpMux = true` 表示使用单个多路复用连接
- `poolCount = 50` 表示使用 50 个独立连接
- 这两个选项互斥,导致连接无法正常建立
**修复**:
```toml
[transport]
tcpMux = true
tcpMuxKeepaliveInterval = 10
# 移除 poolCount
```
### 问题2:未知客户端 bi2 占用资源
**现象**: FRP 服务器上发现多个未知代理
**排查**:
```bash
curl -s -u admin:密码 http://127.0.0.1:7500/api/proxy/tcp
```
**发现**:
| 代理名称 | 远程端口 | 来源 IP | 说明 |
|---------|---------|---------|------|
| youyidian-3d | 2320 | 58.216.227.194 | ✅ 正常(素材库)|
| bi2-ssh | 6000 | 36.153.91.234 | ❓ 未知客户端 |
| bi2-http-80 | 8000 | 36.153.91.234 | ❓ 未知客户端 |
| bi2-http-8080 | 8080 | 36.153.91.234 | ❓ 未知客户端 |
| bi2-mysql | 6002 | 36.153.91.234 | ❓ 未知客户端 |
**bi2 客户端信息**:
- IP: 36.153.91.234
- 位置: 中国重庆市(中国移动)
- 首次连接: 2025-11-17
- 状态: 未经授权使用了 FRP Token
### 问题3:bi2-ssh 被暴力扫描攻击
**现象**: bi2-ssh 端口 6000 被全球 SSH 扫描机器人攻击
**攻击统计** (2026-01-30):
| 攻击源 IP | 连接次数 |
|-----------|----------|
| 209.38.230.85 | 18,976 |
| 193.32.162.46 | 9,882 |
| 193.32.162.101 | 5,629 |
| 94.156.152.194 | 4,435 |
| 45.148.10.66 | 2,904 |
| 45.148.10.195 | 1,294 |
| **总计** | **43,895** |
**影响**:
- 攻击流量消耗 FRP 服务器连接资源
- 导致素材库 (youyidian-3d) 无法获取工作连接
- 出现 `failed to get work connection: control is closed` 错误
---
## 解决方案
### 方案1:修复 FRP 配置
**服务器端 `/etc/frp/frps.toml`**:
```toml
bindAddr = "0.0.0.0"
bindPort = 7000
auth.method = "token"
auth.token = "YYD3D_FRP_20260130_edd41d728bfd59a5"
[transport]
tcpMux = true
tcpMuxKeepaliveInterval = 60
tcpKeepalive = 30
heartbeatTimeout = 90
log.to = "/var/log/frps.log"
log.level = "info"
log.maxDays = 7
```
**客户端 `/etc/frp/frpc.toml`**:
```toml
serverAddr = "180.97.221.140"
serverPort = 7000
[auth]
method = "token"
token = "YYD3D_FRP_20260130_edd41d728bfd59a5"
[transport]
tcpMux = true
tcpMuxKeepaliveInterval = 10
heartbeatInterval = 10
heartbeatTimeout = 30
[[proxies]]
name = "youyidian-3d"
type = "tcp"
localIP = "127.0.0.1"
localPort = 4320
remotePort = 2320
[proxies.healthCheck]
type = "tcp"
intervalSeconds = 15
maxFailed = 3
timeoutSeconds = 5
```
### 方案2:封禁攻击 IP
在 FRP 服务器上使用 firewalld 封禁主要攻击源:
```bash
# 封禁攻击 IP
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=209.38.230.85 drop"
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=193.32.162.46 drop"
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=193.32.162.101 drop"
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=94.156.152.194 drop"
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=45.148.10.66 drop"
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=45.148.10.195 drop"
firewall-cmd --reload
```
### 方案3:更换 FRP Token
踢掉未授权的 bi2 客户端,更换 Token 防止重连:
**旧 Token**: `LiuYunKu_NAS_2024_SecureToken` (已泄露/被滥用)
**新 Token**: `YYD3D_FRP_20260130_edd41d728bfd59a5`
更换步骤:
1. 更新 FRP 服务器配置
2. 更新生产服务器 frpc 配置
3. 先重启 frps,再重启 frpc
### 方案4:部署自动监控脚本
创建 `/root/scripts/frp-monitor.sh`,每分钟检测公网是否可访问:
```bash
#!/bin/bash
# FRP 穿透服务健康监控脚本
# 每分钟检测公网是否可访问,连续失败3次自动重启
LOG="/var/log/frp-monitor.log"
FAIL_COUNT_FILE="/tmp/frp_fail_count"
MAX_FAILS=3
log() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] $1" >> "$LOG"
}
# 核心检测:公网端口能不能真正访问
check_public_access() {
timeout 5 curl -sf "http://180.97.221.140:2320/" > /dev/null 2>&1
}
# 读取失败计数
FAIL_COUNT=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)
if check_public_access; then
# 访问正常
if [ "$FAIL_COUNT" -gt 0 ]; then
log "✅ 服务恢复正常,重置失败计数"
fi
echo 0 > "$FAIL_COUNT_FILE"
else
# 访问失败
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"
log "⚠️ 公网访问检测失败 ($FAIL_COUNT/$MAX_FAILS)"
# 连续失败达到阈值,强制重启
if [ "$FAIL_COUNT" -ge "$MAX_FAILS" ]; then
log "🚨 连续失败 $MAX_FAILS 次,强制重启 FRP"
# 强制杀掉所有 frpc 进程(清理连接池)
killall -9 frpc 2>/dev/null
sleep 2
# 重启 systemd 服务
systemctl start frpc.service
sleep 5
# 验证是否恢复
if check_public_access; then
log "✅ FRP 重启成功,服务已恢复"
else
log "❌ FRP 重启后仍然失败,可能是服务器端问题"
fi
# 重置计数
echo 0 > "$FAIL_COUNT_FILE"
fi
fi
```
**cron 定时任务**:
```bash
* * * * * /bin/bash /root/scripts/frp-monitor.sh
```
---
## 当前状态 (2026-01-30 11:31)
| 项目 | 状态 |
|------|------|
| FRP 服务器 | ✅ 运行正常 |
| 素材库穿透 (youyidian-3d) | ✅ 在线 |
| bi2 客户端 | ✅ 已断开(Token 已更换)|
| 攻击 IP | ✅ 已封禁 6 个主要来源 |
| 监控脚本 | ✅ 已部署(每分钟检测)|
| 公网访问 | ✅ 正常 |
---
## 重要配置信息
### SSH 连接信息
| 服务器 | 别名 | 端口 | 用途 |
|--------|------|------|------|
| 180.97.221.140 | frp | 9527 | FRP 穿透服务器 |
| 192.168.58.20 | prod | 22 | 生产服务器(TrueNAS)|
### FRP Token
**当前有效 Token**: `YYD3D_FRP_20260130_edd41d728bfd59a5`
⚠️ **安全提醒**:
- Token 等同于"入场券",知道就能连接
- 不要在代码仓库、公开文档中暴露
- 定期检查 FRP Dashboard 是否有未知客户端
### 监控日志位置
| 日志 | 路径 | 说明 |
|------|------|------|
| FRP 服务器日志 | `/var/log/frps.log` | frps 运行日志 |
| FRP 客户端日志 | `/root/frp/frpc.log` | frpc 运行日志 |
| 监控脚本日志 | `/var/log/frp-monitor.log` | 健康检查日志 |
---
## 后续建议
1. **定期检查 FRP Dashboard**: 访问 `http://127.0.0.1:7500`(需在服务器本地或 SSH 隧道)
2. **定期更换 Token**: 建议每 3-6 个月更换一次
3. **监控告警**: 可添加钉钉 Webhook 实现告警通知
4. **考虑 IP 白名单**: 在 frps 配置中限制只允许特定 IP 连接
---
## 附录:常用命令
```bash
# 查看 FRP 服务器状态
ssh -p 9527 frp "systemctl status frps"
# 查看 FRP 客户端状态
ssh prod "systemctl status frpc"
# 查看当前连接的客户端
ssh -p 9527 frp "ss -tnp | grep :7000"
# 查看代理状态
ssh -p 9527 frp "curl -s -u admin:Admin@frp2024 http://127.0.0.1:7500/api/proxy/tcp"
# 测试公网访问
curl -I http://180.97.221.140:2320/
# 查看监控日志
ssh prod "tail -50 /var/log/frp-monitor.log"
# 手动重启 FRP
ssh -p 9527 frp "systemctl restart frps"
ssh prod "systemctl restart frpc"
```

View File

@ -255,20 +255,30 @@ export function useAdminState() {
}
}, [showRecycleBin, materialsPageSize, materials.length])
const searchMaterials = useCallback(async (searchTerm: string) => {
// 搜索素材(使用分页加载,每页50条,避免一次加载过多数据)
const SEARCH_PAGE_SIZE = 50
const searchMaterials = useCallback(async (searchTerm: string, page = 1, append = false) => {
try {
const hasSearch = searchTerm.trim().length > 0
const pageSize = hasSearch ? SEARCH_PAGE_SIZE : materialsPageSize
const url = showRecycleBin
? `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}&deleted=true${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
: `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
? `/api/materials?page=${page}&pageSize=${pageSize}&deleted=true${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
: `/api/materials?page=${page}&pageSize=${pageSize}${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
const materialsData = data.materials || data
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
setCurrentMaterialsPage(1)
setHasMoreMaterials(!hasSearch && materialsData.length === materialsPageSize)
if (append) {
setMaterials(prev => [...prev, ...materialsData])
setDisplayedMaterialsCount(prev => prev + materialsData.length)
} else {
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
}
setCurrentMaterialsPage(page)
// 搜索模式下,如果返回数量等于页大小,则可能还有更多
setHasMoreMaterials(materialsData.length === pageSize)
}
} catch (error) {
console.error('Error searching materials:', error)

View File

@ -172,6 +172,8 @@ export function useBatchFilesUpload(
: item.sceneName,
platformName: item.platformName || '',
productCategoryName: item.productCategoryName || '',
// 重要:传递原始文件名,用于保留正确的扩展名(.mp4, .mp3 等)
fileName: item.file.name,
}
// 添加渲染器(3D场景)
@ -232,6 +234,17 @@ export function useBatchFilesUpload(
batchUploadXhrRefs.current.delete(item.id)
}
},
// 取消检查回调:让 chunkUpload 内部能够感知取消状态
isCancelled: () => batchUploadCancelRef.current.cancelled,
// 活跃 XHR 集合回调:跟踪所有并发的 XHR 以便批量取消
onActiveXhrChange: (xhrs) => {
// 将所有活跃的 XHR 添加到 batchUploadXhrRefs(用于取消)
xhrs.forEach(xhr => {
if (!Array.from(batchUploadXhrRefs.current.values()).includes(xhr)) {
batchUploadXhrRefs.current.set(`${item.id}-chunk-${Date.now()}`, xhr)
}
})
},
skipComplete: true,
maxRetries: Infinity,
})
@ -454,10 +467,19 @@ export function useBatchFilesUpload(
}
// 检查是否有失败的文件
const failedFiles = batchUploadStatus?.fileStatuses.filter(s => s.status === 'failed') || []
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
// 注意:使用 results 数组而不是 batchUploadStatus,避免 React 闭包陷阱
// batchUploadStatus 是闭包捕获的旧值,而 results 是循环中实时更新的
const failedResults = results.filter(r => r.status === 'failed')
if (failedResults.length > 0) {
const failedNames = failedResults.map(r => r.fileName).join(', ')
console.error(`[Upload] ${failedResults.length} 个文件上传失败:`, failedNames)
setError(`${failedResults.length} 个文件上传失败: ${failedNames}`)
} else if (results.length === 0) {
// 没有任何结果,可能是全部被跳过
console.warn('[Upload] 批量上传完成但没有任何结果')
setError('上传完成但没有成功的文件,请检查文件格式')
} else {
console.log(`[Upload] 批量上传成功: ${results.length} 个文件`)
setUploadProgress(100)
onComplete()
onSuccess()
@ -470,7 +492,9 @@ export function useBatchFilesUpload(
setUploading(false)
batchUploadXhrRefs.current.clear()
}
}, [materialType, batchUploadStatus, setUploading, setUploadProgress, setUploadSpeed, setBatchUploadStatus, setError, onSuccess, onClose])
// 注意:移除了 batchUploadStatus 依赖,因为我们使用 setBatchUploadStatus 的函数式更新
// 和 results 数组来跟踪状态,避免闭包陷阱
}, [materialType, setUploading, setUploadProgress, setUploadSpeed, setBatchUploadStatus, setError, onSuccess, onClose])
return {
handleBatchFilesUpload,

23
instrumentation.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* Next.js Instrumentation
*
* 这个文件在应用启动时执行,用于:
* 1. 启动临时文件定时清理任务
* 2. 初始化其他需要在启动时运行的服务
*/
export async function register() {
// 只在 Node.js 服务端运行(不在 Edge 运行时)
if (process.env.NEXT_RUNTIME === 'nodejs') {
console.log('[Instrumentation] Next.js 服务端启动...')
// 启动临时文件清理任务
try {
const { startCleanupScheduler } = await import('./lib/temp-cleanup')
startCleanupScheduler()
console.log('[Instrumentation] 临时文件清理任务已启动')
} catch (error) {
console.error('[Instrumentation] 启动清理任务失败:', error)
}
}
}

View File

@ -17,6 +17,8 @@ interface ChunkUploadOptions {
) => void
onRequest?: (xhr: XMLHttpRequest | null) => void // 暴露当前的XHR请求以便取消
skipComplete?: boolean // 如果为true,分片上传完成后立即返回,不调用completeUpload(用于后台异步处理)
isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 暴露所有活跃的XHR请求集合(用于批量取消)
}
interface ChunkUploadState {
@ -324,8 +326,13 @@ export async function chunkUpload(
onStageChange,
onRequest,
skipComplete = false, // 默认不跳过complete
isCancelled, // 取消检查回调
onActiveXhrChange, // 活跃XHR集合回调
} = options
// 跟踪所有活跃的 XHR 请求(用于批量取消)
const activeXhrs = new Set<XMLHttpRequest>()
onStageChange?.('uploading', { message: '正在上传素材文件…' })
const fileId = generateFileId(file)
@ -469,6 +476,12 @@ export async function chunkUpload(
// 并发上传worker
const uploadWorker = async () => {
while (pendingChunks.length > 0) {
// 检查取消状态
if (isCancelled?.()) {
console.log('[ChunkUpload] Worker 检测到取消信号,停止上传')
throw new Error('上传已取消')
}
const chunkIndex = pendingChunks.shift()
if (chunkIndex === undefined) break
@ -484,13 +497,36 @@ export async function chunkUpload(
let chunkUploadedBytes = 0
while (!success && retries < maxRetries) {
// 每次重试前检查取消状态
if (isCancelled?.()) {
console.log('[ChunkUpload] Worker 检测到取消信号,停止上传')
throw new Error('上传已取消')
}
// 当前分片的 XHR 引用
let currentXhr: XMLHttpRequest | null = null
try {
await uploadChunk(state.uploadId!, chunkIndex, chunk, file.name, {
onChunkProgress: (loaded) => {
chunkUploadedBytes = loaded
updateProgress(completedBytes + chunkUploadedBytes)
},
onRequest,
onRequest: (xhr) => {
// 跟踪所有活跃的 XHR
if (xhr) {
currentXhr = xhr
activeXhrs.add(xhr)
onActiveXhrChange?.(activeXhrs)
} else if (currentXhr) {
// XHR 完成,从集合中移除
activeXhrs.delete(currentXhr)
onActiveXhrChange?.(activeXhrs)
currentXhr = null
}
// 同时调用原来的回调
onRequest?.(xhr)
},
})
state.uploadedChunks.add(chunkIndex)
@ -504,13 +540,41 @@ export async function chunkUpload(
console.log(`[ChunkUpload] Chunk ${chunkIndex}/${totalChunks} 完成,剩余 ${pendingChunks.length} 个`)
} catch (error) {
// 清理当前 XHR
if (currentXhr) {
activeXhrs.delete(currentXhr)
onActiveXhrChange?.(activeXhrs)
currentXhr = null
}
if ((error as Error)?.message === '上传已取消') {
onRequest?.(null)
// 取消时清理 IndexedDB 状态
await deleteUploadState(fileId).catch(() => {})
// 调用后端清理 API 删除服务器临时文件
if (state.uploadId) {
console.log('[ChunkUpload] 取消上传,调用后端清理临时文件...')
fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId: state.uploadId })
}).catch(err => {
console.warn('[ChunkUpload] 清理临时文件失败(忽略):', err)
})
}
throw error
}
// 如果是会话过期,尝试刷新会话(保留进度)
if ((error as Error)?.message === 'UPLOAD_SESSION_EXPIRED') {
// ⚠️ 重要修复:如果所有分片都已上传成功,不要重新初始化
// 这种情况可能是 complete 阶段的竞争条件导致的,应该直接继续到 complete
if (state.uploadedChunks.size >= totalChunks) {
console.warn(`[ChunkUpload] ⚠️ 会话过期但所有分片已上传完成 (${state.uploadedChunks.size}/${totalChunks}),跳过重新初始化`)
sessionExpired = false
continue
}
// 如果已经有其他worker在处理,等待它完成
if (reinitPromise) {
console.log(`[ChunkUpload] 等待其他worker完成会话刷新...`)
@ -540,6 +604,13 @@ export async function chunkUpload(
// 不清空 uploadedChunks,保留进度
return
} else {
// ⚠️ 修复:如果刷新失败但所有分片已上传,不要重新初始化
if (state.uploadedChunks.size >= totalChunks) {
console.warn(`[ChunkUpload] ⚠️ 会话刷新失败但所有分片已上传完成,跳过重新初始化`)
sessionExpired = false
return
}
console.warn(`[ChunkUpload] ⚠️ 会话刷新失败(${refreshResult.error}),重新初始化上传...`)
// 刷新失败,需要重新初始化
state.uploadId = await initUpload(file.name, file.size, totalChunks)
@ -630,6 +701,14 @@ export async function chunkUpload(
// 如果是会话过期错误,不再重试,直接抛出
if (error instanceof Error && error.message === 'UPLOAD_SESSION_EXPIRED') {
await deleteUploadState(fileId)
// 清理后端临时文件
if (state.uploadId) {
fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId: state.uploadId })
}).catch(() => {})
}
throw new Error('上传会话已过期,请重新上传文件')
}
@ -641,6 +720,15 @@ export async function chunkUpload(
await deleteUploadState(fileId).catch(() => {
console.warn('[ChunkUpload] 清理上传状态失败(忽略)')
})
// 清理后端临时文件
if (state.uploadId) {
console.log('[ChunkUpload] 上传失败,调用后端清理临时文件...')
fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId: state.uploadId })
}).catch(() => {})
}
throw new Error('完成上传失败,已重试多次')
}
}

View File

@ -5,8 +5,10 @@
import jwt from 'jsonwebtoken';
// Token 有效期:24小时
const TOKEN_EXPIRY = '24h';
// Token 有效期:永不过期
// 注意:任务平台会缓存下载链接,所以设置为永不过期
// 安全性依赖于 DOWNLOAD_TOKEN_SECRET 的保密性
const TOKEN_EXPIRY = '100y'; // 100年 ≈ 永不过期
// 下载类型
export type DownloadType = 'all' | 'main' | 'sku' | 'detail' | 'psd' | 'single';

View File

@ -32,7 +32,7 @@ const REQUIRED_ENV_VARS = [
// NextAuth
'NEXTAUTH_SECRET',
'NEXTAUTH_URL',
// NEXTAUTH_URL - 改为可选,让 NextAuth 根据请求自动检测(支持内网/公网自适应)
// S3/MinIO(如果使用S3存储)
// 'S3_ENDPOINT',
@ -44,24 +44,29 @@ const REQUIRED_ENV_VARS = [
// 'REDIS_URL',
// 'USE_REDIS',
// 管理员API密钥
'ADMIN_API_KEY',
// 管理员API密钥 - 改为可选,不阻塞应用启动
// 'ADMIN_API_KEY',
]
/**
* 可选但建议配置的环境变量
*/
const OPTIONAL_ENV_VARS = [
// NextAuth URL(可选,不设置时 NextAuth 会自动检测)
'NEXTAUTH_URL',
// S3/MinIO
'S3_ENDPOINT',
'S3_BUCKET',
'S3_ACCESS_KEY_ID',
'S3_SECRET_ACCESS_KEY',
// Redis
'REDIS_URL',
'USE_REDIS',
'DINGTALK_APP_KEY',
'DINGTALK_APP_SECRET',
'DOWNLOAD_TOKEN_SECRET',
'DISABLE_CACHE',
'ADMIN_API_KEY', // 管理员API密钥(用于外部API调用验证)
]
/**

36
lib/highlight.tsx Normal file
View File

@ -0,0 +1,36 @@
import React from 'react'
/**
* 高亮文本中的关键词
* @param text 原始文本
* @param keyword 关键词
* @returns ReactNode
*/
export function highlightText(text: string, keyword: string): React.ReactNode {
if (!keyword || !text || !keyword.trim()) return text
// 转义正则特殊字符
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
try {
const regex = new RegExp(`(${escapeRegex(keyword.trim())})`, 'gi')
const parts = text.split(regex)
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-transparent text-blue-600 dark:text-blue-400 font-bold px-0">
{part}
</mark>
) : (
part
)
)}
</>
)
} catch (e) {
console.error('Highlight error:', e)
return text
}
}

View File

@ -249,15 +249,17 @@ export function detectIsIntranet(request: NextRequest): boolean {
// 1.2 如果 hostname 是公网域名,检查客户端IP
if (isPublicDomain(hostname)) {
// 判断用户是否在内网的条件(更宽松):
// 1. clientIp 是内网IP
// 2. 或者 X-Forwarded-For 中包含内网IP(说明经过了内网代理)
const isIntranetUser = (clientIp && isInternalIP(clientIp)) || hasInternalIpInForwarded
// 判断用户是否在内网的条件(严格版本):
// 只检查真正的客户端IP是否为内网IP
// 注意:不能用 hasInternalIpInForwarded,因为反向代理/负载均衡器的内网IP会导致误判
// 例如:公网用户 -> Nginx代理(192.168.x.x) -> 应用,X-Forwarded-For 中会包含代理的内网IP
const isIntranetUser = !!(clientIp && isInternalIP(clientIp))
if (debugNetworkDetection) {
console.log('[Network Detection] isPublicDomain:', true)
console.log('[Network Detection] clientIp:', clientIp)
console.log('[Network Detection] clientIp isInternal:', clientIp ? isInternalIP(clientIp) : 'N/A')
console.log('[Network Detection] hasInternalIpInForwarded:', hasInternalIpInForwarded)
console.log('[Network Detection] hasInternalIpInForwarded (仅供参考,不用于判断):', hasInternalIpInForwarded)
console.log('[Network Detection] 判断结果:', isIntranetUser ? '内网用户' : '外网用户')
console.log('[Network Detection] ====================================')
}

186
lib/spu-parser.ts Normal file
View File

@ -0,0 +1,186 @@
/**
* SPU 型号解析器
* 从 SPU 型号中自动识别店铺和品类
*
* SPU 格式说明:
* - GBPM-SN0204 → 店铺: 朴美, 品类: 收纳柜斗柜角柜飘窗
* - XYL-BJ0301 → 店铺: 橡野里, 品类: 边几
* - SN0204 → 店铺: 用户手动选, 品类: 收纳柜斗柜角柜飘窗
*/
/**
* SPU 解析结果
*/
export interface SPUParseResult {
/** 是否成功解析到店铺 */
shopMatched: boolean
/** 店铺名称(如果识别成功) */
shopName: string
/** 是否成功解析到品类 */
categoryMatched: boolean
/** 品类名称(如果识别成功) */
categoryName: string
/** 品类代码(如 SN, XG 等) */
categoryCode: string
}
/**
* 店铺前缀映射表
* 前缀 → 店铺名称
*/
const SHOP_PREFIX_MAP: Record<string, string> = {
'GBPM': '朴美',
'GBSFY': '索菲亚',
'GBGJ': '顾家',
'XYL': '橡野里',
}
/**
* 品类代码映射表
* 代码 → 品类名称
*/
const CATEGORY_CODE_MAP: Record<string, string> = {
// 收纳类
'SN': '收纳柜斗柜角柜飘窗',
'DG': '收纳柜斗柜角柜飘窗',
// 柜类
'XG': '鞋柜',
'BG': '边柜',
'SG': '书柜',
'CBG': '餐边柜',
// 桌类
'CZ': '餐桌',
'SZ': '书桌',
'DNZ': '电脑桌',
'SJZ': '电脑桌',
'ZDZ': '电脑桌',
'CBZ': '电脑桌',
// 椅凳类
'CY': '椅子',
'AD': '椅子',
'SZD': '椅子',
'HXD': '换鞋凳',
// 架类
'SJ': '花架书架',
'ZSJ': '花架书架',
'ZWJ': '置物架',
'CFZW': '置物架',
'YMJ': '衣帽架',
// 几类
'BJ': '边几',
'CJ': '电视柜茶几',
'DSG': '电视柜茶几',
'CTG': '床头柜',
// 其他
'CYJ': '穿衣镜',
'SZT': '化妆台',
'XJ': '鞋架',
'LRSF': '沙发',
}
/**
* 获取所有店铺前缀(按长度降序排列,优先匹配长前缀)
*/
const SHOP_PREFIXES = Object.keys(SHOP_PREFIX_MAP).sort((a, b) => b.length - a.length)
/**
* 获取所有品类代码(按长度降序排列,优先匹配长代码)
*/
const CATEGORY_CODES = Object.keys(CATEGORY_CODE_MAP).sort((a, b) => b.length - a.length)
/**
* 解析 SPU 型号,提取店铺和品类信息
*
* @param spu SPU 型号字符串
* @returns 解析结果
*
* @example
* parseSPU('GBPM-SN0204')
* // { shopMatched: true, shopName: '朴美', categoryMatched: true, categoryName: '收纳柜斗柜角柜飘窗', categoryCode: 'SN' }
*
* parseSPU('XYL-BJ0301')
* // { shopMatched: true, shopName: '橡野里', categoryMatched: true, categoryName: '边几', categoryCode: 'BJ' }
*
* parseSPU('SN0204')
* // { shopMatched: false, shopName: '', categoryMatched: true, categoryName: '收纳柜斗柜角柜飘窗', categoryCode: 'SN' }
*/
export function parseSPU(spu: string): SPUParseResult {
const result: SPUParseResult = {
shopMatched: false,
shopName: '',
categoryMatched: false,
categoryName: '',
categoryCode: '',
}
if (!spu || typeof spu !== 'string') {
return result
}
// 统一转大写处理
const upperSPU = spu.toUpperCase().trim()
if (!upperSPU) {
return result
}
let remainingPart = upperSPU
// 1. 尝试匹配店铺前缀(格式:PREFIX-...)
for (const prefix of SHOP_PREFIXES) {
if (upperSPU.startsWith(prefix + '-')) {
result.shopMatched = true
result.shopName = SHOP_PREFIX_MAP[prefix]
remainingPart = upperSPU.substring(prefix.length + 1) // 去掉前缀和连字符
break
}
}
// 2. 尝试匹配品类代码(格式:CODE + 数字)
for (const code of CATEGORY_CODES) {
// 品类代码后面必须跟数字
const regex = new RegExp(`^${code}\\d`)
if (regex.test(remainingPart)) {
result.categoryMatched = true
result.categoryName = CATEGORY_CODE_MAP[code]
result.categoryCode = code
break
}
}
return result
}
/**
* 获取所有可用的店铺前缀列表(用于提示)
*/
export function getShopPrefixes(): string[] {
return Object.keys(SHOP_PREFIX_MAP)
}
/**
* 获取所有可用的品类代码列表(用于提示)
*/
export function getCategoryCodes(): string[] {
return Object.keys(CATEGORY_CODE_MAP)
}
/**
* 获取店铺前缀映射表(用于调试或显示)
*/
export function getShopPrefixMap(): Record<string, string> {
return { ...SHOP_PREFIX_MAP }
}
/**
* 获取品类代码映射表(用于调试或显示)
*/
export function getCategoryCodeMap(): Record<string, string> {
return { ...CATEGORY_CODE_MAP }
}

209
lib/temp-cleanup.ts Normal file
View File

@ -0,0 +1,209 @@
/**
* 临时文件自动清理模块
*
* 功能:
* 1. 应用启动时清理过期的 temp 目录
* 2. 定时清理(每小时检查一次)
* 3. 提供手动清理接口
*
* 清理规则:
* - 超过 8 小时的 temp 目录
* - 且在 Redis 中没有活跃会话
*/
import { promises as fs } from 'fs'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const TEMP_DIR = path.join(UPLOAD_DIR, 'temp')
const MAX_AGE_HOURS = 8 // 8小时后清理
const MAX_AGE_MS = MAX_AGE_HOURS * 60 * 60 * 1000
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000 // 每小时检查一次
let cleanupTimer: NodeJS.Timeout | null = null
let isCleanupRunning = false
/**
* 检查上传会话是否在 Redis 中活跃
*/
async function isSessionActive(uploadId: string): Promise<boolean> {
try {
const { hasUploadSession } = await import('./upload-sessions-redis')
return await hasUploadSession(uploadId)
} catch (error) {
// Redis 不可用时,保守处理,认为会话可能活跃
console.warn('[TempCleanup] 检查会话失败:', error)
return false
}
}
/**
* 清理单个 temp 目录
*/
async function cleanupTempDir(dirPath: string): Promise<{ success: boolean; freedBytes: number }> {
let freedBytes = 0
try {
// 计算目录大小
const files = await fs.readdir(dirPath)
for (const file of files) {
try {
const filePath = path.join(dirPath, file)
const stat = await fs.stat(filePath)
if (stat.isFile()) {
freedBytes += stat.size
}
} catch {
// 忽略单个文件错误
}
}
// 删除目录及内容
await fs.rm(dirPath, { recursive: true, force: true })
return { success: true, freedBytes }
} catch (error) {
console.warn(`[TempCleanup] 删除目录失败: ${dirPath}`, error)
return { success: false, freedBytes: 0 }
}
}
/**
* 执行清理任务
*/
export async function runCleanup(): Promise<{
cleaned: number
failed: number
freedBytes: number
skippedActive: number
}> {
if (isCleanupRunning) {
console.log('[TempCleanup] 清理任务正在运行,跳过')
return { cleaned: 0, failed: 0, freedBytes: 0, skippedActive: 0 }
}
isCleanupRunning = true
const startTime = Date.now()
let cleaned = 0
let failed = 0
let freedBytes = 0
let skippedActive = 0
try {
// 检查 temp 目录是否存在
try {
await fs.access(TEMP_DIR)
} catch {
console.log('[TempCleanup] temp 目录不存在,跳过清理')
return { cleaned, failed, freedBytes, skippedActive }
}
// 遍历 temp 目录
const dirs = await fs.readdir(TEMP_DIR)
const now = Date.now()
console.log(`[TempCleanup] 检查 ${dirs.length} 个目录...`)
for (const dir of dirs) {
const dirPath = path.join(TEMP_DIR, dir)
try {
const stat = await fs.stat(dirPath)
if (!stat.isDirectory()) continue
// 检查是否过期(使用目录修改时间)- 先检查时间,减少 Redis 查询
const age = now - stat.mtimeMs
if (age < MAX_AGE_MS) {
continue // 还没过期,跳过
}
// 检查是否是活跃会话(只对过期目录检查)
const isActive = await isSessionActive(dir)
if (isActive) {
skippedActive++
continue
}
// 执行清理
const result = await cleanupTempDir(dirPath)
if (result.success) {
cleaned++
freedBytes += result.freedBytes
} else {
failed++
}
} catch (error) {
failed++
console.warn(`[TempCleanup] 处理目录失败: ${dir}`, error)
}
}
const duration = Date.now() - startTime
const freedMB = (freedBytes / 1024 / 1024).toFixed(2)
console.log(`[TempCleanup] 清理完成: 删除 ${cleaned} 个目录,释放 ${freedMB} MB,跳过活跃 ${skippedActive} 个,耗时 ${duration}ms`)
} finally {
isCleanupRunning = false
}
return { cleaned, failed, freedBytes, skippedActive }
}
/**
* 启动定时清理任务
*/
export function startCleanupScheduler(): void {
if (cleanupTimer) {
console.log('[TempCleanup] 定时任务已在运行')
return
}
console.log('[TempCleanup] 启动定时清理任务(每小时执行)')
// 立即执行一次
runCleanup().catch(err => {
console.error('[TempCleanup] 启动时清理失败:', err)
})
// 设置定时任务
cleanupTimer = setInterval(() => {
runCleanup().catch(err => {
console.error('[TempCleanup] 定时清理失败:', err)
})
}, CLEANUP_INTERVAL_MS)
// 确保定时器不阻止进程退出
cleanupTimer.unref()
}
/**
* 停止定时清理任务
*/
export function stopCleanupScheduler(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer)
cleanupTimer = null
console.log('[TempCleanup] 定时清理任务已停止')
}
}
/**
* 清理指定的上传会话临时目录
* 用于上传完成或取消时立即清理
*/
export async function cleanupUploadSession(uploadId: string): Promise<boolean> {
const dirPath = path.join(TEMP_DIR, uploadId)
try {
await fs.access(dirPath)
} catch {
// 目录不存在,视为已清理
return true
}
const result = await cleanupTempDir(dirPath)
if (result.success) {
console.log(`[TempCleanup] 已清理上传会话: ${uploadId}`)
}
return result.success
}

View File

@ -57,6 +57,8 @@ const nextConfig = {
],
},
experimental: {
// 启用 instrumentation hook(应用启动时执行)
instrumentationHook: true,
serverActions: {
bodySizeLimit: '10gb', // Server Actions 的限制 (10GB)
},
@ -97,4 +99,3 @@ const nextConfig = {
}
module.exports = nextConfig

View File

@ -122,6 +122,8 @@ model Material {
@@index([transferredAt])
@@index([taskNo])
@@index([taskStatus])
@@index([productModel])
@@index([authorName])
@@map("materials")
}