perf: 创意资产库第一页加速,空壳改为权威标记
This commit is contained in:
parent
656d4a2794
commit
8009c62868
@ -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
|
||||
|
||||
@ -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<string>();
|
||||
@ -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<string, string | null>();
|
||||
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<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string }>();
|
||||
const assetsByMaterial = new Map<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string; preview?: string }>();
|
||||
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) {
|
||||
|
||||
@ -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原图不需要渲染器
|
||||
},
|
||||
});
|
||||
|
||||
@ -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原图不需要渲染器
|
||||
},
|
||||
});
|
||||
|
||||
@ -199,6 +199,7 @@ describe('certificate report complete data safety', () => {
|
||||
zipFileName: 'report.jpg',
|
||||
zipFileSize: BigInt(4),
|
||||
taskStatus: 'completed',
|
||||
isIncompleteShell: false,
|
||||
}),
|
||||
}))
|
||||
expect(cleanupTempFiles).toHaveBeenCalledWith(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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: '', // 实拍原图不需要渲染器
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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: '', // 视频截图不需要渲染器
|
||||
},
|
||||
});
|
||||
|
||||
@ -104,6 +104,7 @@ export default function Home() {
|
||||
const pathname = usePathname()
|
||||
const [materials, setMaterials] = useState<Material[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
|
||||
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
if (status === 'unauthenticated') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */}
|
||||
<MaterialsHeader session={session} />
|
||||
<MaterialsHeader session={session ?? null} />
|
||||
{/* 维护通知横幅已在 Header 中显示 */}
|
||||
|
||||
{/* 收藏操作提示气泡 */}
|
||||
@ -1349,6 +1358,7 @@ export default function Home() {
|
||||
isFilterCollapsed={isFilterCollapsed}
|
||||
setIsFilterCollapsed={setIsFilterCollapsed}
|
||||
themeColor={themeColor}
|
||||
facetsLoading={facetsLoading || status === 'loading'}
|
||||
/>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">类型分页</h2>
|
||||
{selectedMaterialType && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}
|
||||
<h2 className="text-sm font-medium text-gray-700">类型分页</h2>
|
||||
{selectedMaterialType && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedMaterialType('')} className={`${btnBase} ${selectedMaterialType === '' ? btnActive : btnInactive}`} style={selectedMaterialType === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMaterialType === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.materialType})</span></button>
|
||||
<div className="flex flex-wrap gap-2" aria-busy={facetsLoading}>
|
||||
<button onClick={() => setSelectedMaterialType('')} className={`${btnBase} ${selectedMaterialType === '' ? btnActive : btnInactive}`} style={selectedMaterialType === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMaterialType === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.materialType)})</span></button>
|
||||
{materialTypes.map((type) => (
|
||||
<button key={type.value} onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)} className={`${btnBase} ${selectedMaterialType === type.value ? btnActive : btnInactive}`} style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}>{type.label}<span className={`ml-1.5 text-xs ${selectedMaterialType === type.value ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({type.count || 0})</span></button>
|
||||
<button key={type.value} onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)} className={`${btnBase} ${selectedMaterialType === type.value ? btnActive : btnInactive}`} style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}>{type.label}<span className={`ml-1.5 text-xs ${selectedMaterialType === type.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(type.count || 0)})</span></button>
|
||||
))}
|
||||
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
|
||||
<span key={item} className="h-9 w-20 rounded-full bg-[#eceef0]" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<button onClick={() => setIsFilterCollapsed(!isFilterCollapsed)} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<div className="mb-4 border-t border-gray-200 pt-4">
|
||||
<button onClick={() => setIsFilterCollapsed(!isFilterCollapsed)} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors">
|
||||
<svg className={`w-4 h-4 transition-transform duration-200 ${isFilterCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
|
||||
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded text-xs">{activeFilterCount}</span>}
|
||||
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">{activeFilterCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">使用店铺</h2>{selectedCategory && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">使用店铺</h2>{selectedCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedCategory('')} className={`${btnBase} ${selectedCategory === '' ? btnActive : btnInactive}`} style={selectedCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedCategory === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.category})</span></button>
|
||||
{categories.map((c) => <button key={c.id} onClick={() => setSelectedCategory(c.name === selectedCategory ? '' : c.name)} className={`${btnBase} ${selectedCategory === c.name ? btnActive : btnInactive}`} style={selectedCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedCategory === c.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({c.count || 0})</span></button>)}
|
||||
<button onClick={() => setSelectedCategory('')} className={`${btnBase} ${selectedCategory === '' ? btnActive : btnInactive}`} style={selectedCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.category)})</span></button>
|
||||
{categories.map((c) => <button key={c.id} onClick={() => setSelectedCategory(c.name === selectedCategory ? '' : c.name)} className={`${btnBase} ${selectedCategory === c.name ? btnActive : btnInactive}`} style={selectedCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">场景类别</h2>{selectedScene && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">场景类别</h2>{selectedScene && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedScene('')} className={`${btnBase} ${selectedScene === '' ? btnActive : btnInactive}`} style={selectedScene === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedScene === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.scene})</span></button>
|
||||
{scenes.map((s) => <button key={s.id} onClick={() => setSelectedScene(s.name === selectedScene ? '' : s.name)} className={`${btnBase} ${selectedScene === s.name ? btnActive : btnInactive}`} style={selectedScene === s.name ? { backgroundColor: themeColor } : {}}>{s.name}<span className={`ml-1.5 text-xs ${selectedScene === s.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({s.count || 0})</span></button>)}
|
||||
<button onClick={() => setSelectedScene('')} className={`${btnBase} ${selectedScene === '' ? btnActive : btnInactive}`} style={selectedScene === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedScene === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.scene)})</span></button>
|
||||
{scenes.map((s) => <button key={s.id} onClick={() => setSelectedScene(s.name === selectedScene ? '' : s.name)} className={`${btnBase} ${selectedScene === s.name ? btnActive : btnInactive}`} style={selectedScene === s.name ? { backgroundColor: themeColor } : {}}>{s.name}<span className={`ml-1.5 text-xs ${selectedScene === s.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(s.count || 0)})</span></button>)}
|
||||
</div>
|
||||
</div>
|
||||
{platforms.length > 0 && <div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">平台分类</h2>{selectedPlatform && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
|
||||
{(platforms.length > 0 || facetsLoading) && <div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">平台分类</h2>{selectedPlatform && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedPlatform('')} className={`${btnBase} ${selectedPlatform === '' ? btnActive : btnInactive}`} style={selectedPlatform === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedPlatform === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.platform})</span></button>
|
||||
{platforms.map((p) => <button key={p.id} onClick={() => setSelectedPlatform(p.name === selectedPlatform ? '' : p.name)} className={`${btnBase} ${selectedPlatform === p.name ? btnActive : btnInactive}`} style={selectedPlatform === p.name ? { backgroundColor: themeColor } : {}}>{p.name}<span className={`ml-1.5 text-xs ${selectedPlatform === p.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({p.count || 0})</span></button>)}
|
||||
<button onClick={() => setSelectedPlatform('')} className={`${btnBase} ${selectedPlatform === '' ? btnActive : btnInactive}`} style={selectedPlatform === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedPlatform === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.platform)})</span></button>
|
||||
{platforms.map((p) => <button key={p.id} onClick={() => setSelectedPlatform(p.name === selectedPlatform ? '' : p.name)} className={`${btnBase} ${selectedPlatform === p.name ? btnActive : btnInactive}`} style={selectedPlatform === p.name ? { backgroundColor: themeColor } : {}}>{p.name}<span className={`ml-1.5 text-xs ${selectedPlatform === p.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(p.count || 0)})</span></button>)}
|
||||
</div>
|
||||
</div>}
|
||||
{productCategories.length > 0 && <div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">产品类别</h2>{selectedProductCategory && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
|
||||
{(productCategories.length > 0 || facetsLoading) && <div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">产品类别</h2>{selectedProductCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedProductCategory('')} className={`${btnBase} ${selectedProductCategory === '' ? btnActive : btnInactive}`} style={selectedProductCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedProductCategory === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.productCategory})</span></button>
|
||||
{productCategories.map((c) => <button key={c.id} onClick={() => setSelectedProductCategory(c.name === selectedProductCategory ? '' : c.name)} className={`${btnBase} ${selectedProductCategory === c.name ? btnActive : btnInactive}`} style={selectedProductCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedProductCategory === c.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({c.count || 0})</span></button>)}
|
||||
<button onClick={() => setSelectedProductCategory('')} className={`${btnBase} ${selectedProductCategory === '' ? btnActive : btnInactive}`} style={selectedProductCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedProductCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.productCategory)})</span></button>
|
||||
{productCategories.map((c) => <button key={c.id} onClick={() => setSelectedProductCategory(c.name === selectedProductCategory ? '' : c.name)} className={`${btnBase} ${selectedProductCategory === c.name ? btnActive : btnInactive}`} style={selectedProductCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedProductCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">上传月份</h2></div>
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">上传月份</h2></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setSelectedMonth('')} className={`${btnBase} ${selectedMonth === '' ? btnActive : btnInactive}`} style={selectedMonth === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMonth === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.month})</span></button>
|
||||
{months.map((m) => <button key={m.value} onClick={() => setSelectedMonth(m.value === selectedMonth ? '' : m.value)} className={`${btnBase} ${selectedMonth === m.value ? btnActive : btnInactive}`} style={selectedMonth === m.value ? { backgroundColor: themeColor } : {}}>{m.label}<span className={`ml-1.5 text-xs ${selectedMonth === m.value ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({m.count})</span></button>)}
|
||||
<button onClick={() => setSelectedMonth('')} className={`${btnBase} ${selectedMonth === '' ? btnActive : btnInactive}`} style={selectedMonth === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMonth === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.month)})</span></button>
|
||||
{months.map((m) => <button key={m.value} onClick={() => setSelectedMonth(m.value === selectedMonth ? '' : m.value)} className={`${btnBase} ${selectedMonth === m.value ? btnActive : btnInactive}`} style={selectedMonth === m.value ? { backgroundColor: themeColor } : {}}>{m.label}<span className={`ml-1.5 text-xs ${selectedMonth === m.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(m.count)})</span></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">排序方式</h2></div>
|
||||
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">排序方式</h2></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => handleSortChange('createdAt')} className={`${btnBase} flex items-center gap-1 ${sortBy === 'createdAt' ? btnActive : btnInactive}`} style={sortBy === 'createdAt' ? { backgroundColor: themeColor } : {}}>
|
||||
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
|
||||
@ -159,7 +165,7 @@ function FilterPanel(props: FilterPanelProps) {
|
||||
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">💡 提示:可以组合使用多个筛选条件,找到更精确的素材</div>
|
||||
<div className="text-xs text-gray-500 mt-2">可以组合使用多个筛选条件,找到更精确的素材</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
aria-label="素材列表加载中"
|
||||
>
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<article
|
||||
key={index}
|
||||
className="overflow-hidden rounded-[14px] border border-[#e7e8ea] bg-white"
|
||||
>
|
||||
<div data-skeleton-block className="h-[400px] bg-[#eceef0]" />
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div data-skeleton-block className="h-4 w-3/4 bg-[#eceef0]" />
|
||||
<div data-skeleton-block className="h-3 w-1/2 bg-[#f2f3f5]" />
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div data-skeleton-block className="h-3 w-20 bg-[#f2f3f5]" />
|
||||
<div data-skeleton-block className="h-3 w-12 bg-[#f2f3f5]" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MaterialGrid({
|
||||
materials,
|
||||
displayedCount,
|
||||
@ -76,26 +133,21 @@ export default function MaterialGrid({
|
||||
<div className="relative">
|
||||
{/* 过渡加载遮罩 */}
|
||||
{isTransitionLoading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-[1px]">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-gray-900 dark:border-gray-700 dark:border-t-white rounded-full animate-spin" />
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/60 backdrop-blur-[1px]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[#e7e8ea] border-t-[#1a1c1d]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">加载中...</p>
|
||||
</div>
|
||||
<MaterialGridSkeleton />
|
||||
) : materials.length === 0 ? (
|
||||
// 空状态
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-lg text-[#6b7075]">
|
||||
{hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// 素材网格
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{materials.slice(0, displayedCount).map((material) => (
|
||||
<MaterialCard
|
||||
key={material.id}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
SPECIAL_INIT_MATERIAL_TYPES,
|
||||
buildIncompleteSpecialInitShellWhere,
|
||||
buildMaterialSearchOrConditions,
|
||||
buildMaterialWhereFromSearchParams,
|
||||
createMaterialQueryContext,
|
||||
@ -126,7 +125,8 @@ describe('material search conditions', () => {
|
||||
)
|
||||
|
||||
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)(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -132,6 +132,7 @@ describe('product-package init route', () => {
|
||||
// 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段,
|
||||
// 更不能兜底成邮箱
|
||||
authorName: 'tester',
|
||||
isIncompleteShell: true,
|
||||
}),
|
||||
})
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@ -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}'`);
|
||||
|
||||
@ -55,6 +55,7 @@ describe('任务-页面上传接口', () => {
|
||||
data: expect.objectContaining({
|
||||
materialType: '任务-页面',
|
||||
taskStatus: 'uploading',
|
||||
isIncompleteShell: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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') || ''
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
@ -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])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user