From 8009c62868194e97c3861561fdc415527d2ce980 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 17 Aug 2026 11:39:40 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=88=9B=E6=84=8F=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=BA=93=E7=AC=AC=E4=B8=80=E9=A1=B5=E5=8A=A0=E9=80=9F=EF=BC=8C?= =?UTF-8?q?=E7=A9=BA=E5=A3=B3=E6=94=B9=E4=B8=BA=E6=9D=83=E5=A8=81=E6=A0=87?= =?UTF-8?q?=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 +- app/api/materials/route.ts | 114 +++++++++--------- .../upload/3d-original/init/route.ts | 2 + .../upload/ai-original/init/route.ts | 2 + .../certificate-report/complete/route.test.ts | 1 + .../certificate-report/complete/route.ts | 2 + .../upload/certificate-report/init/route.ts | 2 + .../upload/product-package/init/route.ts | 2 + .../materials/upload/real-photo/init/route.ts | 2 + .../materials/upload/task-page/init/route.ts | 2 + .../upload/video-screenshot/init/route.ts | 2 + app/materials/page.tsx | 46 ++++--- components/home/FilterPanel.tsx | 62 +++++----- components/home/MaterialGrid.tsx | 78 ++++++++++-- lib/__tests__/material-query.test.ts | 4 +- .../materials-route-ai-generated.test.ts | 101 ++++++++++++---- .../product-package-init-route.test.ts | 1 + .../product-package-task-no-lock.test.ts | 3 +- ...uggestions-special-init-visibility.test.ts | 3 +- .../task-image-complete-route-safety.test.ts | 1 + lib/__tests__/task-page-upload-routes.test.ts | 1 + lib/material-query.ts | 37 ++---- lib/product-package.ts | 4 + .../migration.sql | 43 +++++++ prisma/schema.prisma | 4 + 25 files changed, 351 insertions(+), 172 deletions(-) create mode 100644 prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql diff --git a/Dockerfile b/Dockerfile index ea4b9d7..81bbb5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260812_align_material_upload_idempotency_index/migration.sql" && \ test -f "/app/prisma/migrations/20260813_add_material_custody_department/migration.sql" && \ test -f "/app/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql" && \ - test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql" + test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql" && \ + test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -172,6 +173,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_align_mate COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_material_custody_department ./prisma/migrations/20260813_add_material_custody_department COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_organization_units_and_employment_state ./prisma/migrations/20260813_add_organization_units_and_employment_state COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_user_employee_binding ./prisma/migrations/20260813_add_user_employee_binding +COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_material_is_incomplete_shell ./prisma/migrations/20260817_add_material_is_incomplete_shell COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 611dfff..e38f546 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -297,30 +297,36 @@ export async function GET(request: NextRequest) { if (empty) { return NextResponse.json({ materials: [], + hasMore: false, totalPages: 0, total: 0, totalCount: 0, currentPage: page, }); } - - // 获取总数 - let totalCount: number; - try { - totalCount = await prisma.material.count({ where }); - } catch (e: any) { - if (e?.meta?.column?.includes('deletedAt')) { - console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); - const whereWithoutDeleted = { ...where }; - delete whereWithoutDeleted.deletedAt; - totalCount = await prisma.material.count({ where: whereWithoutDeleted }); - } else { - throw e; + + // 创意资产库首页用 deferTotal=1:先返回当前页,不阻塞全库 count。 + // 后台管理等仍走原来的 total/totalPages。 + const deferTotal = searchParams.get('deferTotal') === '1'; + let totalCount: number | null = null; + let totalPages: number | null = null; + + if (!deferTotal) { + try { + totalCount = await prisma.material.count({ where }); + } catch (e: any) { + if (e?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); + const whereWithoutDeleted = { ...where }; + delete whereWithoutDeleted.deletedAt; + totalCount = await prisma.material.count({ where: whereWithoutDeleted }); + } else { + throw e; + } } + totalPages = Math.ceil(totalCount / pageSize); } - const totalPages = Math.ceil(totalCount / pageSize); - // 排序 - 使用 id 作为次要排序键,确保分页排序稳定(避免重复数据) let orderBy: any = [{ createdAt: sortOrder }, { id: 'asc' }]; if (sortBy === 'downloadCount') { @@ -373,36 +379,13 @@ export async function GET(request: NextRequest) { avatar: true, }, }, - // 包含 assets 数据(任务-详情需要) - assets: { - select: { - id: true, - assetType: true, - fileUrl: true, - fileName: true, - fileSize: true, - sortOrder: true, - createdAt: true, - updatedAt: true, - skuCode: true, - barcodes: { - select: { - barcode: true, - }, - }, - }, - where: { - assetType: { in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'] }, - }, - orderBy: { sortOrder: 'asc' }, - }, }, }; if (sortBy !== 'comprehensive') { query.orderBy = orderBy; query.skip = (page - 1) * pageSize; - query.take = pageSize; + query.take = pageSize + (deferTotal ? 1 : 0); } else { // comprehensive排序:获取更多数据用于内存排序,但有上限 // 只需要前 (page * pageSize) 条数据,最多 MAX_QUERY_LIMIT 条 @@ -640,6 +623,18 @@ export async function GET(request: NextRequest) { throw e; } } + + let hasMore = false; + if (deferTotal && sortBy !== 'comprehensive') { + hasMore = materials.length > pageSize; + if (hasMore) { + materials = materials.slice(0, pageSize); + } + } else if (totalPages != null) { + hasMore = page < totalPages; + } else { + hasMore = materials.length === pageSize; + } // 批量查询点赞和收藏状态 const likedSet = new Set(); @@ -677,20 +672,22 @@ export async function GET(request: NextRequest) { } // 格式化返回数据 - // 对于任务-详情类型,需要从 assets 中获取正确的预览图 + // 任务-详情 / 缺封面的任务-页面:单独批量取封面,避免列表主查询联资产表 const productPackageMaterialIds = materials .filter(m => isProductPackageType(m.materialType)) .map(m => m.id); + const taskPageMissingPreviewIds = materials + .filter(m => m.materialType === '任务-页面' && !m.previewUrl && !m.videoCoverUrl) + .map(m => m.id); + const coverMaterialIds = [...new Set([...productPackageMaterialIds, ...taskPageMissingPreviewIds])]; - // 批量获取 Product Package 素材的封面图(优化:使用单次查询) const coverUrlMap = new Map(); - if (productPackageMaterialIds.length > 0) { + if (coverMaterialIds.length > 0) { try { - // 批量查询所有 Product Package 素材的主图和SKU图 const assets = await prisma.materialAsset.findMany({ where: { - materialId: { in: productPackageMaterialIds }, - assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview'] }, + materialId: { in: coverMaterialIds }, + assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] }, }, select: { materialId: true, @@ -705,15 +702,13 @@ export async function GET(request: NextRequest) { ], }); - // 按 materialId 分组,优先使用预览图(main-preview/sku-preview),没有预览图则使用原图(main/sku) - const assetsByMaterial = new Map(); + const assetsByMaterial = new Map(); for (const asset of assets) { if (!assetsByMaterial.has(asset.materialId)) { assetsByMaterial.set(asset.materialId, {}); } const materialAssets = assetsByMaterial.get(asset.materialId)!; - // 分别记录预览图和原图 if (asset.assetType === 'main-preview') { materialAssets.mainPreview = asset.fileUrl; } else if (asset.assetType === 'sku-preview') { @@ -722,19 +717,19 @@ export async function GET(request: NextRequest) { materialAssets.main = asset.fileUrl; } else if (asset.assetType === 'sku') { materialAssets.sku = asset.fileUrl; + } else if (asset.assetType === 'preview' && !materialAssets.preview) { + materialAssets.preview = asset.fileUrl; } } - // 构建封面图映射:优先使用预览图,没有预览图才使用原图 assetsByMaterial.forEach((materialAssets, materialId) => { - const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.main || materialAssets.sku || null; + const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.preview || materialAssets.main || materialAssets.sku || null; if (coverUrl) { coverUrlMap.set(materialId, coverUrl); } }); } catch (error) { console.error('[Materials API] Failed to batch get cover URLs:', error); - // 如果批量查询失败,回退到逐个查询 await Promise.all( productPackageMaterialIds.map(async (materialId) => { try { @@ -755,9 +750,9 @@ export async function GET(request: NextRequest) { let previewUrl = material.previewUrl || material.videoCoverUrl || ''; if (!previewUrl && material.materialType === '任务-页面') { - const previewAsset = (material.assets || []).find((asset: any) => asset.assetType === 'preview'); - if (previewAsset) { - previewUrl = previewAsset.fileUrl; + const coverUrl = coverUrlMap.get(material.id); + if (coverUrl) { + previewUrl = coverUrl; } } @@ -855,10 +850,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ materials: formattedMaterials, - totalPages, - total: totalCount, // 添加total字段以兼容前端 - totalCount, + hasMore, currentPage: page, + ...(totalCount != null + ? { + total: totalCount, + totalCount, + totalPages, + } + : {}), }); } catch (error) { if (error instanceof MaterialQueryError) { diff --git a/app/api/materials/upload/3d-original/init/route.ts b/app/api/materials/upload/3d-original/init/route.ts index 0372daf..4aee92c 100644 --- a/app/api/materials/upload/3d-original/init/route.ts +++ b/app/api/materials/upload/3d-original/init/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo } from '@/lib/product-package'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; // POST - 初始化3D原图素材记录 export async function POST(request: NextRequest) { @@ -80,6 +81,7 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, renderer: '', // 3D原图不需要渲染器 }, }); diff --git a/app/api/materials/upload/ai-original/init/route.ts b/app/api/materials/upload/ai-original/init/route.ts index 7c4cec5..e4e9883 100644 --- a/app/api/materials/upload/ai-original/init/route.ts +++ b/app/api/materials/upload/ai-original/init/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo } from '@/lib/product-package'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; // POST - 初始化AI原图素材记录 export async function POST(request: NextRequest) { @@ -84,6 +85,7 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, renderer: '', // AI原图不需要渲染器 }, }); diff --git a/app/api/materials/upload/certificate-report/complete/route.test.ts b/app/api/materials/upload/certificate-report/complete/route.test.ts index fa7bf96..5b2a95e 100644 --- a/app/api/materials/upload/certificate-report/complete/route.test.ts +++ b/app/api/materials/upload/certificate-report/complete/route.test.ts @@ -199,6 +199,7 @@ describe('certificate report complete data safety', () => { zipFileName: 'report.jpg', zipFileSize: BigInt(4), taskStatus: 'completed', + isIncompleteShell: false, }), })) expect(cleanupTempFiles).toHaveBeenCalledWith( diff --git a/app/api/materials/upload/certificate-report/complete/route.ts b/app/api/materials/upload/certificate-report/complete/route.ts index 6a362a3..fe6b615 100644 --- a/app/api/materials/upload/certificate-report/complete/route.ts +++ b/app/api/materials/upload/certificate-report/complete/route.ts @@ -23,6 +23,7 @@ import { createMaterialLog } from '@/lib/material-log' import { deleteFromS3 } from '@/lib/s3' import { generatePdfPreview } from '@/lib/pdf-processor' import { inspectBoundedRegularFile } from '@/lib/bounded-file-reader' +import { LIST_VISIBLE_MATERIAL_DATA } from '@/lib/material-query' import { getOwnedUploadedFile } from '@/lib/upload-sessions' import { acquireTaskMaterialCompletionLock, @@ -446,6 +447,7 @@ export async function POST(request: NextRequest) { zipFileName: firstFileName, zipFileSize: BigInt(totalFileSize), taskStatus: 'completed', + ...LIST_VISIBLE_MATERIAL_DATA, } as any, }) if (finalizeResult.count !== 1) { diff --git a/app/api/materials/upload/certificate-report/init/route.ts b/app/api/materials/upload/certificate-report/init/route.ts index 1b062ff..77e3c4d 100644 --- a/app/api/materials/upload/certificate-report/init/route.ts +++ b/app/api/materials/upload/certificate-report/init/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { CERTIFICATE_REPORT_MATERIAL_TYPE, normalizeReportTitle } from '@/lib/certificate-report' +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query' export async function POST(request: NextRequest) { try { @@ -50,6 +51,7 @@ export async function POST(request: NextRequest) { zipFilePath: '', zipFileName: '', taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, } as any, }) diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index cb29fb3..311ba82 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma'; import { validateTaskNo, isTaskNoExists } from '@/lib/product-package'; import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; export async function POST(request: NextRequest) { try { @@ -148,6 +149,7 @@ export async function POST(request: NextRequest) { taskDetailSourceType, taskDetailRequesterUserId: taskDetailRequester.id, taskDetailRequesterName: taskDetailRequester.realName || taskDetailRequester.username, + ...INCOMPLETE_SHELL_CREATE_DATA, } as any, }); diff --git a/app/api/materials/upload/real-photo/init/route.ts b/app/api/materials/upload/real-photo/init/route.ts index 166c5e9..a602be1 100644 --- a/app/api/materials/upload/real-photo/init/route.ts +++ b/app/api/materials/upload/real-photo/init/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo } from '@/lib/product-package'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; // POST - 初始化实拍原图素材记录 export async function POST(request: NextRequest) { @@ -84,6 +85,7 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, renderer: '', // 实拍原图不需要渲染器 }, }); diff --git a/app/api/materials/upload/task-page/init/route.ts b/app/api/materials/upload/task-page/init/route.ts index ac71570..4844716 100644 --- a/app/api/materials/upload/task-page/init/route.ts +++ b/app/api/materials/upload/task-page/init/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo } from '@/lib/product-package'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; // POST - 初始化任务-页面素材记录 export async function POST(request: NextRequest) { @@ -85,6 +86,7 @@ export async function POST(request: NextRequest) { zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // 任务-页面不需要渲染器 taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, }, }); diff --git a/app/api/materials/upload/video-screenshot/init/route.ts b/app/api/materials/upload/video-screenshot/init/route.ts index 6ba760c..688e003 100644 --- a/app/api/materials/upload/video-screenshot/init/route.ts +++ b/app/api/materials/upload/video-screenshot/init/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo } from '@/lib/product-package'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'; // POST - 初始化视频截图素材记录 export async function POST(request: NextRequest) { @@ -84,6 +85,7 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 taskStatus: 'uploading', + ...INCOMPLETE_SHELL_CREATE_DATA, renderer: '', // 视频截图不需要渲染器 }, }); diff --git a/app/materials/page.tsx b/app/materials/page.tsx index 2dd1a05..c08d7d8 100644 --- a/app/materials/page.tsx +++ b/app/materials/page.tsx @@ -104,6 +104,7 @@ export default function Home() { const pathname = usePathname() const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) + const [facetsLoading, setFacetsLoading] = useState(true) const [showUploadModal, setShowUploadModal] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [selectedCategory, setSelectedCategory] = useState('') @@ -205,6 +206,7 @@ export default function Home() { if (includePaging) { params.append('page', page.toString()) params.append('pageSize', pageSize.toString()) + params.append('deferTotal', '1') } return params @@ -353,7 +355,7 @@ export default function Home() { useEffect(() => { if (typeof window === 'undefined') return // 只在首页路径时执行 - if (pathname !== '/') return + if (pathname !== '/materials') return // 避免重复恢复 if (hasRestoredRef.current) return @@ -482,7 +484,7 @@ export default function Home() { // 当从其他页面返回首页时,重置恢复标记 useEffect(() => { - if (pathname === '/') { + if (pathname === '/materials') { // 延迟重置,确保恢复逻辑已执行 const timer = setTimeout(() => { hasRestoredRef.current = false @@ -600,15 +602,23 @@ export default function Home() { }) } else { setMaterials(materialsList) - setDisplayedCount(Math.min(24, materialsList.length)) + setDisplayedCount(materialsList.length) } const appendedCount = append ? appendedItems.length : materialsList.length setCurrentPage(page) - const totalPages = data.totalPages || 1 - setHasMore(appendedCount > 0 && page < totalPages) - setCurrentMaterialsTotalCount(data.total ?? data.totalCount ?? materialsList.length) + if (typeof data.hasMore === 'boolean') { + setHasMore(data.hasMore) + } else { + const totalPages = data.totalPages || 1 + setHasMore(appendedCount > 0 && page < totalPages) + } + if (typeof data.total === 'number') { + setCurrentMaterialsTotalCount(data.total) + } else if (typeof data.totalCount === 'number') { + setCurrentMaterialsTotalCount(data.totalCount) + } // 对需要转码的视频素材启动轮询 appendedItems.forEach((material, index) => { @@ -947,7 +957,7 @@ export default function Home() { // 如果当前显示的数量小于已加载的素材数量,直接增加显示数量 if (displayedCount < materials.length) { setIsLoadingMore(true) - setDisplayedCount((prev) => Math.min(prev + 24, materials.length)) + setDisplayedCount(materials.length) setIsLoadingMore(false) return } @@ -959,7 +969,7 @@ export default function Home() { setIsLoadingMore(true) const appended = await fetchMaterials(currentPage + 1, true) if (appended > 0) { - setDisplayedCount((prev) => Math.min(prev + 24, materials.length + appended)) + setDisplayedCount((prev) => prev + appended) } setIsLoadingMore(false) }, [isLoadingMore, displayedCount, materials.length, hasMore, currentPage, fetchMaterials]) @@ -971,6 +981,7 @@ export default function Home() { facetsAbortRef.current?.abort() const controller = new AbortController() facetsAbortRef.current = controller + setFacetsLoading(true) try { const params = buildFilterParams({ includeSort: false, includePaging: false }) @@ -985,6 +996,9 @@ export default function Home() { setProductCategories(data.productCategories || []) setMonths(data.months || []) setFacetTotals({ ...EMPTY_FACET_TOTALS, ...(data.totals || {}) }) + if (typeof data.total === 'number') { + setCurrentMaterialsTotalCount(data.total) + } setMaterialTypeCounts( Object.fromEntries( ((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]), @@ -998,6 +1012,9 @@ export default function Home() { showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' }) } } finally { + if (requestId === facetsRequestIdRef.current) { + setFacetsLoading(false) + } if (facetsAbortRef.current === controller) facetsAbortRef.current = null } }, [buildFilterParams, showToast]) @@ -1268,22 +1285,14 @@ export default function Home() { } } - if (status === 'loading' || loading) { - return ( -
-
加载中...
-
- ) - } - - if (!session) { + if (status === 'unauthenticated') { return null } return (
{/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */} - + {/* 维护通知横幅已在 Header 中显示 */} {/* 收藏操作提示气泡 */} @@ -1349,6 +1358,7 @@ export default function Home() { isFilterCollapsed={isFilterCollapsed} setIsFilterCollapsed={setIsFilterCollapsed} themeColor={themeColor} + facetsLoading={facetsLoading || status === 'loading'} /> {/* Active Filters Display */} diff --git a/components/home/FilterPanel.tsx b/components/home/FilterPanel.tsx index 13acb5f..d0f4e82 100644 --- a/components/home/FilterPanel.tsx +++ b/components/home/FilterPanel.tsx @@ -52,6 +52,7 @@ interface FilterPanelProps { isFilterCollapsed: boolean setIsFilterCollapsed: (value: boolean) => void themeColor: string + facetsLoading?: boolean } function FilterPanel(props: FilterPanelProps) { @@ -66,6 +67,7 @@ function FilterPanel(props: FilterPanelProps) { materialTypes, categories, scenes, platforms, productCategories, months, totalMaterialsCount, materialsCount, facetTotals, isFilterCollapsed, setIsFilterCollapsed, themeColor, + facetsLoading = false, } = props const handleSortChange = useCallback((newSortBy: string) => { @@ -88,67 +90,71 @@ function FilterPanel(props: FilterPanelProps) { const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length const btnBase = 'px-4 py-2 rounded-full text-sm font-medium transition-all' const btnActive = 'text-white' - const btnInactive = 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' + const btnInactive = 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50' + const countText = (value: number) => (facetsLoading ? '…' : String(value)) return ( <>
-

类型分页

- {selectedMaterialType && {materialsCount} 个素材} +

类型分页

+ {selectedMaterialType && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
-
- +
+ {materialTypes.map((type) => ( - + + ))} + {facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => ( +
-
-
-

使用店铺

{selectedCategory && {materialsCount} 个素材}
+

使用店铺

{selectedCategory && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
- - {categories.map((c) => )} + + {categories.map((c) => )}
-

场景类别

{selectedScene && {materialsCount} 个素材}
+

场景类别

{selectedScene && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
- - {scenes.map((s) => )} + + {scenes.map((s) => )}
- {platforms.length > 0 &&
-

平台分类

{selectedPlatform && {materialsCount} 个素材}
+ {(platforms.length > 0 || facetsLoading) &&
+

平台分类

{selectedPlatform && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
- - {platforms.map((p) => )} + + {platforms.map((p) => )}
} - {productCategories.length > 0 &&
-

产品类别

{selectedProductCategory && {materialsCount} 个素材}
+ {(productCategories.length > 0 || facetsLoading) &&
+

产品类别

{selectedProductCategory && {facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}}
- - {productCategories.map((c) => )} + + {productCategories.map((c) => )}
}
-

上传月份

+

上传月份

- - {months.map((m) => )} + + {months.map((m) => )}
-

排序方式

+

排序方式

-
💡 提示:可以组合使用多个筛选条件,找到更精确的素材
+
可以组合使用多个筛选条件,找到更精确的素材
diff --git a/components/home/MaterialGrid.tsx b/components/home/MaterialGrid.tsx index ad4492e..4c8ef44 100644 --- a/components/home/MaterialGrid.tsx +++ b/components/home/MaterialGrid.tsx @@ -1,8 +1,12 @@ 'use client' -import React from 'react' +import React, { useRef } from 'react' +import gsap from 'gsap' +import { useGSAP } from '@gsap/react' import MaterialCard, { Material } from './MaterialCard' +gsap.registerPlugin(useGSAP) + // 用户会话类型 interface UserSession { id: string @@ -44,6 +48,59 @@ interface MaterialGridProps { showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void } +function MaterialGridSkeleton() { + const rootRef = useRef(null) + + useGSAP(() => { + if (typeof window.matchMedia !== 'function') return + + const motion = gsap.matchMedia() + motion.add('(prefers-reduced-motion: no-preference)', () => { + gsap.fromTo( + '[data-skeleton-block]', + { opacity: 0.42 }, + { + opacity: 0.92, + duration: 1.15, + ease: 'sine.inOut', + yoyo: true, + repeat: -1, + stagger: { each: 0.08, from: 'start' }, + }, + ) + }) + + return () => motion.revert() + }, { scope: rootRef }) + + return ( +
+ {Array.from({ length: 8 }, (_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} + export default function MaterialGrid({ materials, displayedCount, @@ -76,26 +133,21 @@ export default function MaterialGrid({
{/* 过渡加载遮罩 */} {isTransitionLoading && ( -
-
+
+
)} - - {/* 加载状态 */} + {loading ? ( -
-

加载中...

-
+ ) : materials.length === 0 ? ( - // 空状态 -
-

+

+

{hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}

) : ( - // 素材网格 -
+
{materials.slice(0, displayedCount).map((material) => ( { ) expect(result.empty).toBe(false) - expect(result.where.NOT).toEqual(buildIncompleteSpecialInitShellWhere()) + expect(result.where.isIncompleteShell).toBe(false) + expect(result.where.NOT).toBeUndefined() }) test.each(SPECIAL_INIT_MATERIAL_TYPES)( diff --git a/lib/__tests__/materials-route-ai-generated.test.ts b/lib/__tests__/materials-route-ai-generated.test.ts index 754be54..2242d36 100644 --- a/lib/__tests__/materials-route-ai-generated.test.ts +++ b/lib/__tests__/materials-route-ai-generated.test.ts @@ -11,7 +11,7 @@ import { isSuperAdmin, isSystemAdmin, } from '@/lib/department-permission' -import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query' + jest.mock('next-auth', () => ({ getServerSession: jest.fn(), @@ -146,7 +146,6 @@ describe('materials route ai generated', () => { realName: '张三', avatar: null, }, - assets: [], }, ]) ;(prisma.materialLike.findMany as jest.Mock).mockResolvedValue([]) @@ -166,7 +165,7 @@ describe('materials route ai generated', () => { expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ - NOT: buildIncompleteSpecialInitShellWhere(), + isIncompleteShell: false, }), select: expect.objectContaining({ isAiGenerated: true, @@ -262,20 +261,14 @@ describe('materials route ai generated', () => { realName: '张三', avatar: null, }, - assets: [ - { - id: 'preview-1', - assetType: 'preview', - fileUrl: 'https://example.com/task-page-preview.webp', - fileName: 'preview-0.webp', - fileSize: null, - sortOrder: 0, - createdAt: new Date('2026-06-26T08:00:00.000Z'), - updatedAt: new Date('2026-06-26T08:00:00.000Z'), - skuCode: null, - barcodes: [], - }, - ], + }, + ]) + ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([ + { + materialId: 'task-page-1', + assetType: 'preview', + fileUrl: 'https://example.com/task-page-preview.webp', + sortOrder: 0, }, ]) @@ -287,20 +280,78 @@ describe('materials route ai generated', () => { expect(response.status).toBe(200) expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ - select: expect.objectContaining({ - assets: expect.objectContaining({ - where: { - assetType: { - in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'], - }, - }, - }), + select: expect.not.objectContaining({ + assets: expect.anything(), + }), + take: 20, + }), + ) + expect(prisma.materialAsset.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + materialId: { in: ['task-page-1'] }, + assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] }, }), }), ) expect(body.materials[0].previewUrl).toBe('https://example.com/task-page-preview.webp') }) + test('创意资产库 deferTotal 时不应先做全库 count,并用多取 1 条判断是否还有下一页', async () => { + const pageItems = Array.from({ length: 21 }, (_, index) => ({ + id: `material-${index + 1}`, + userId: 'user-1', + previewUrl: `https://example.com/preview-${index + 1}.jpg`, + productModel: `SPU-${index + 1}`, + categoryName: '旗舰店', + sceneName: '客厅', + platformName: '天猫', + productCategoryName: '沙发', + materialType: '任务-详情', + renderer: '', + authorName: '张三', + zipFileName: 'sample.zip', + zipFileSize: BigInt(1024), + downloadCount: 0, + likeCount: 0, + createdAt: new Date('2026-03-25T08:00:00.000Z'), + videoSourceUrl: null, + displayImages: [], + videoPreview540Url: null, + videoPreviewClipUrl: null, + videoCoverUrl: null, + videoDuration: null, + videoProcessingStatus: null, + transcodeLastError: null, + transcodeAttempts: 0, + isAiGenerated: false, + taskDetailSourceType: null, + user: { + username: 'zhangsan', + realName: '张三', + avatar: null, + }, + })) + ;(prisma.material.findMany as jest.Mock).mockResolvedValue(pageItems) + + const request = new NextRequest('http://localhost/api/materials?page=1&pageSize=20&sortBy=createdAt&deferTotal=1') + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(prisma.material.count).not.toHaveBeenCalled() + expect(prisma.material.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 21, + }), + ) + expect(body.materials).toHaveLength(20) + expect(body.hasMore).toBe(true) + expect(body.totalCount).toBeUndefined() + expect(body.totalPages).toBeUndefined() + }) + test('uploadId 状态查询仍应直接返回 uploading 空壳,不套用普通列表隐藏条件', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'uploading-shell-1', diff --git a/lib/__tests__/product-package-init-route.test.ts b/lib/__tests__/product-package-init-route.test.ts index 32dfe4f..9a1a402 100644 --- a/lib/__tests__/product-package-init-route.test.ts +++ b/lib/__tests__/product-package-init-route.test.ts @@ -132,6 +132,7 @@ describe('product-package init route', () => { // 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段, // 更不能兜底成邮箱 authorName: 'tester', + isIncompleteShell: true, }), }) expect(prisma.user.findUnique).toHaveBeenCalledWith({ diff --git a/lib/__tests__/product-package-task-no-lock.test.ts b/lib/__tests__/product-package-task-no-lock.test.ts index aed81b7..39a60eb 100644 --- a/lib/__tests__/product-package-task-no-lock.test.ts +++ b/lib/__tests__/product-package-task-no-lock.test.ts @@ -138,7 +138,7 @@ describe('任务号完成写入并发锁', () => { expect(order).toEqual(['assets', 'completed']) expect(tx.material.update).toHaveBeenCalledWith({ where: { id: 'material-1' }, - data: { taskStatus: 'completed' }, + data: { taskStatus: 'completed', isIncompleteShell: false }, }) }) @@ -249,6 +249,7 @@ describe('任务号完成写入并发锁', () => { where: { id: 'material-1' }, data: { dingtalkApprovalNo: '202508211445000157693', + isIncompleteShell: false, taskStatus: 'completed', }, }) diff --git a/lib/__tests__/search-suggestions-special-init-visibility.test.ts b/lib/__tests__/search-suggestions-special-init-visibility.test.ts index c07b185..d7ab779 100644 --- a/lib/__tests__/search-suggestions-special-init-visibility.test.ts +++ b/lib/__tests__/search-suggestions-special-init-visibility.test.ts @@ -10,7 +10,6 @@ import { isSuperAdmin, isSystemAdmin, } from '@/lib/department-permission' -import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), @@ -71,7 +70,7 @@ describe('search suggestions special init visibility', () => { for (const [query] of (prisma.material.findMany as jest.Mock).mock.calls) { expect(query.where.AND[0]).toEqual(expect.objectContaining({ deletedAt: null, - NOT: buildIncompleteSpecialInitShellWhere(), + isIncompleteShell: false, })) } }) diff --git a/lib/__tests__/task-image-complete-route-safety.test.ts b/lib/__tests__/task-image-complete-route-safety.test.ts index 2123ca3..43cbc91 100644 --- a/lib/__tests__/task-image-complete-route-safety.test.ts +++ b/lib/__tests__/task-image-complete-route-safety.test.ts @@ -20,6 +20,7 @@ describe.each(routeCases)('%s upload route safety', (routeName, materialType, fa expect(initSource).toContain(`materialType: '${materialType}'`); expect(initSource).toContain("taskStatus: 'uploading'"); + expect(initSource).toContain('INCOMPLETE_SHELL_CREATE_DATA'); expect(completeSource).toContain('completeTaskImageMaterial({'); expect(completeSource).toContain(`materialType: '${materialType}'`); expect(completeSource).toContain(`fallbackProductModel: '${fallbackProductModel}'`); diff --git a/lib/__tests__/task-page-upload-routes.test.ts b/lib/__tests__/task-page-upload-routes.test.ts index e33028f..b177ec9 100644 --- a/lib/__tests__/task-page-upload-routes.test.ts +++ b/lib/__tests__/task-page-upload-routes.test.ts @@ -55,6 +55,7 @@ describe('任务-页面上传接口', () => { data: expect.objectContaining({ materialType: '任务-页面', taskStatus: 'uploading', + isIncompleteShell: true, }), }), ); diff --git a/lib/material-query.ts b/lib/material-query.ts index df63f1b..d1aba18 100644 --- a/lib/material-query.ts +++ b/lib/material-query.ts @@ -146,28 +146,16 @@ export function isProvablyIncompleteSpecialInitShell( && hasNonEmptyString(material.taskDetailRequesterUserId) } +export const INCOMPLETE_SHELL_CREATE_DATA = { + isIncompleteShell: true, +} as const + +export const LIST_VISIBLE_MATERIAL_DATA = { + isIncompleteShell: false, +} as const + export function buildIncompleteSpecialInitShellWhere(): Prisma.MaterialWhereInput { - return { - AND: [ - { materialType: { in: [...SPECIAL_INIT_MATERIAL_TYPES] } }, - { previewUrl: '' }, - { zipFilePath: '' }, - { zipFileName: '' }, - { displayImages: { isEmpty: true } }, - { assets: { none: {} } }, - { - OR: [ - { taskStatus: { in: [...PROVABLY_INCOMPLETE_TASK_STATUSES] } }, - { - materialType: '任务-详情', - taskStatus: null, - taskDetailSourceType: { in: [...TASK_DETAIL_SOURCE_TYPE_OPTIONS] }, - taskDetailRequesterUserId: { not: null }, - }, - ], - }, - ], - } + return { isIncompleteShell: true } } function unique(values: string[]): string[] { @@ -384,10 +372,9 @@ export async function buildMaterialWhereFromSearchParams( where.deletedAt = { not: null } } else { where.deletedAt = null - // 专用上传会先创建 Material 再完成文件落库。统一查询层隐藏可证明未完成、 - // 且完全没有文件证据的空壳,因此列表、筛选计数和搜索建议保持一致。 - // 按 ID / uploadId 的状态查询不经过这里,上传重试和状态恢复仍可访问原记录。 - where.NOT = buildIncompleteSpecialInitShellWhere() + // 列表只读上传时写下的权威标记,不再现场推断空壳。 + // 按 ID / uploadId 的状态查询不经过这里,上传重试仍可访问原记录。 + where.isIncompleteShell = false } const category = searchParams.get('category') || '' diff --git a/lib/product-package.ts b/lib/product-package.ts index b0e4c5d..e6aa803 100644 --- a/lib/product-package.ts +++ b/lib/product-package.ts @@ -9,6 +9,7 @@ import { getDingtalkApprovalNoError, normalizeDingtalkApprovalNo, } from './dingtalk-approval-no'; +import { LIST_VISIBLE_MATERIAL_DATA } from './material-query'; // 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复) // 示例: @@ -448,6 +449,7 @@ export async function finalizeTaskMaterialOnComplete(input: { ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } : {}), taskStatus: TASK_STATUS.COMPLETED, + ...LIST_VISIBLE_MATERIAL_DATA, } as any, }); }); @@ -509,6 +511,7 @@ export async function finalizeTaskMaterialOnComplete(input: { ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } : {}), taskStatus: TASK_STATUS.COMPLETED, + ...LIST_VISIBLE_MATERIAL_DATA, } as any, }); }); @@ -572,6 +575,7 @@ export async function createTaskMaterialOnComplete(input: { ...(normalizedTaskNo ? { taskNo: normalizedTaskNo } : {}), dingtalkApprovalNo: normalizedDingtalkApprovalNo, taskStatus: TASK_STATUS.COMPLETED, + ...LIST_VISIBLE_MATERIAL_DATA, } as any, include: { user: { diff --git a/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql b/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql new file mode 100644 index 0000000..b170594 --- /dev/null +++ b/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql @@ -0,0 +1,43 @@ +-- 素材列表可见性权威标记 +-- +-- 专用 init 会先建一条还没有文件的 Material。以前列表每次用 +-- preview/zip/assets.none 现场判断空壳,打开创意资产库就会拖慢。 +-- 现在把结论写在素材行上:init=true,complete=false,列表只读本列。 +-- +-- 可重复执行。默认 false,不影响已有可展示素材。 + +BEGIN; + +ALTER TABLE "materials" + ADD COLUMN IF NOT EXISTS is_incomplete_shell BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS materials_is_incomplete_shell_idx + ON "materials"(is_incomplete_shell); + +UPDATE "materials" +SET is_incomplete_shell = true +WHERE "deletedAt" IS NULL + AND "materialType" IN ( + '任务-3D原图', + '任务-AI原图', + '任务-实拍原图', + '任务-视频截图', + '任务-页面', + '证书-检测报告', + '任务-详情' + ) + AND "previewUrl" = '' + AND "zipFilePath" = '' + AND "zipFileName" = '' + AND COALESCE(cardinality("displayImages"), 0) = 0 + AND ( + task_status IN ('uploading', 'processing', 'failed') + OR ( + "materialType" = '任务-详情' + AND task_status IS NULL + AND task_detail_source_type IN ('实拍', '渲染', 'AI') + AND task_detail_requester_user_id IS NOT NULL + ) + ); + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4fc22f9..6876c90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,6 +115,9 @@ model Material { taskDetailSourceType String? @map("task_detail_source_type") taskDetailRequesterUserId String? @map("task_detail_requester_user_id") taskDetailRequesterName String? @map("task_detail_requester_name") + /// 专用 init 建档时写 true,完成落库后写 false。列表/筛选只读这个标记, + /// 不再每次去资产表现场判断是不是上传空壳。 + isIncompleteShell Boolean @default(false) @map("is_incomplete_shell") assets MaterialAsset[] downloadLogs MaterialDownloadLog[] favorites MaterialFavorite[] @@ -145,6 +148,7 @@ model Material { @@index([taskNo]) @@index([dingtalkApprovalNo]) @@index([taskStatus]) + @@index([isIncompleteShell]) @@index([isFullSet]) @@index([productModel]) @@index([reportTitle])