diff --git a/CLAUDE.md b/CLAUDE.md index f9b025a..b764126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 优化查找 - ### 🔒 数据安全 - 绝不删除重要数据目录 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 7db8302..4e8e15b 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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} /> )} diff --git a/app/api/admin/materials/batch-delete/route.ts b/app/api/admin/materials/batch-delete/route.ts index c7d9196..71753a7 100644 --- a/app/api/admin/materials/batch-delete/route.ts +++ b/app/api/admin/materials/batch-delete/route.ts @@ -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 }) } } diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 9bab226..e1bbd4c 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -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' }]; } // 查询素材列表 diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 5f96279..26562eb 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -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); } } } diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts index 92268e4..7549ad3 100644 --- a/app/api/proxy-media/route.ts +++ b/app/api/proxy-media/route.ts @@ -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); diff --git a/app/api/search/suggestions/route.ts b/app/api/search/suggestions/route.ts new file mode 100644 index 0000000..4abf7a6 --- /dev/null +++ b/app/api/search/suggestions/route.ts @@ -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() + 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 }) + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 60e98e5..cba0f8c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(null) // 收藏操作提示 const { toast, showToast } = useToast() // 统一的Toast提示 - const searchDebounceRef = useRef(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(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() { - {/* Search Bar - 使用新组件 */} + {/* Search Bar - 实时搜索 */} {/* 筛选面板组件 */} @@ -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} diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index 9ca90a2..b430c6f 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -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(null) // 详情模板展示图片拖拽区域 const previewDropZoneRef = useRef(null) // 预览图片拖拽区域 const xhrRef = useRef(null) + const singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志 + const singleUploadXhrsRef = useRef>(new Set()) // 单文件上传的所有活跃XHR const batchFileInputRef = useRef(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) => { diff --git a/components/admin/MaterialManagement.tsx b/components/admin/MaterialManagement.tsx index 5f36686..0ff25dd 100644 --- a/components/admin/MaterialManagement.tsx +++ b/components/admin/MaterialManagement.tsx @@ -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> displayedMaterialsCount: number + setDisplayedMaterialsCount: React.Dispatch> hasMoreMaterials: boolean + setHasMoreMaterials: React.Dispatch> isLoadingMoreMaterials: boolean showToast: (message: string, type?: 'success' | 'error') => void - searchMaterials: (searchTerm: string) => Promise loadMoreMaterials: () => Promise fetchData: () => Promise + 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(null) - const materialSearchDebounceRef = useRef(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(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 (
- {/* 素材搜索框和批量操作 */} -
- {/* 搜索框 */} -
- 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" - /> - - - - {materialSearchTerm && ( + {/* 顶部工具栏:状态切换 + 操作 + 搜索框 */} +
+ + {/* 左侧:Tab 切换 + 分隔线 + 常用操作 */} +
+ {/* 状态切换 (Segmented Control) */} +
+ +
+ + {/* 回收站模式提示 */} + {showRecycleBin && ( +
+ + + + 回收站模式 + - 显示已删除素材,删除操作为永久删除 + {totalMaterialsCount !== undefined && ( + (共 {totalMaterialsCount} 条) + )} +
)} -
- {/* 批量操作工具栏 */} -
-
+
+ + {/* 批量操作按钮组 */} +
+ {selectedMaterials.size > 0 && ( - + <> +
+ 已选 {selectedMaterials.size} +
+ + + {showRecycleBin && !isBatchProcessing && ( + + )} + )} -
- - 共 {materials.length} 个{showRecycleBin ? '(回收站)' : ''} - +
+ + {/* 右侧:搜索框 */} +
+
{/* 卡片网格布局 */} -
+
{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 (
- {/* 选择框 */} - + } + }} + 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' + }`} + > + {/* 选择指示器 (更像原生选择) */} +
+ {isSelected && ( + + + + )} +
{/* 预览图/视频 */} -
+
{isVideo && videoUrl ? (