fix: 修复批量上传和视频扩展名问题
主要修复: - 修复批量上传闭包陷阱,防止文件丢失 - 添加 fileName 参数传递原始文件名,保留正确扩展名 - 添加取消检查回调,改进批量上传取消功能 其他改进: - 优化搜索功能和管理后台 - 添加 FRP 故障诊断文档 - 更新环境验证和网络检测
This commit is contained in:
parent
4e89b57798
commit
752b7bf999
61
CLAUDE.md
61
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 优化查找
|
||||
|
||||
### 🔒 数据安全
|
||||
|
||||
- 绝不删除重要数据目录
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' }];
|
||||
}
|
||||
|
||||
// 查询素材列表
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
72
app/api/search/suggestions/route.ts
Normal file
72
app/api/search/suggestions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
148
app/page.tsx
148
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<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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -61,6 +61,8 @@ interface MaterialCardProps {
|
||||
downloadProgress: number
|
||||
// 主题色
|
||||
themeColor: string
|
||||
// 筛选状态(用于空状态提示)
|
||||
hasFilters?: boolean
|
||||
// 事件处理
|
||||
onAudioPlay: (materialId: string, audioUrl: string) => void
|
||||
onDownload: (id: string) => void
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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场景' && (
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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' : ''}`}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
322
docs/FRP穿透服务故障诊断与修复-20260130.md
Normal file
322
docs/FRP穿透服务故障诊断与修复-20260130.md
Normal 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"
|
||||
```
|
||||
@ -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)
|
||||
|
||||
@ -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
23
instrumentation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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('完成上传失败,已重试多次')
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
36
lib/highlight.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
186
lib/spu-parser.ts
Normal 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
209
lib/temp-cleanup.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -122,6 +122,8 @@ model Material {
|
||||
@@index([transferredAt])
|
||||
@@index([taskNo])
|
||||
@@index([taskStatus])
|
||||
@@index([productModel])
|
||||
@@index([authorName])
|
||||
@@map("materials")
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user